From 732cfdd167de0aa8e8a97648690b370f6b9f1b47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 02:22:19 +0000 Subject: [PATCH 01/23] chore: add a SynAc verification skill and feature map Teach agents how to launch the local stack, doctor it, drive the content CLI and the glossary UI, and keep proof artifacts after cleanup. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 242 +++++++++++++++++ .../skills/verify-synac/playwright.config.mjs | 31 +++ .../references/features/README.md | 77 ++++++ .../verify-synac/references/features/about.md | 22 ++ .../references/features/acronyms-browse.md | 24 ++ .../references/features/changelog.md | 25 ++ .../references/features/command-palette.md | 34 +++ .../references/features/content-check.md | 39 +++ .../references/features/content-compile.md | 31 +++ .../references/features/content-history.md | 28 ++ .../references/features/content-sync.md | 32 +++ .../verify-synac/references/features/entry.md | 30 ++ .../verify-synac/references/features/home.md | 32 +++ .../references/features/ingest.md | 55 ++++ .../references/features/journeys.md | 26 ++ .../verify-synac/references/features/legal.md | 23 ++ .../references/features/not-found.md | 27 ++ .../references/features/public-api.md | 38 +++ .../references/features/recent.md | 24 ++ .../references/features/search.md | 34 +++ .../references/features/site-chrome.md | 25 ++ .../references/features/sources.md | 27 ++ .../verify-synac/references/features/tags.md | 27 ++ .../references/features/terms-browse.md | 30 ++ .../verify-synac/references/features/theme.md | 25 ++ .../skills/verify-synac/scripts/cleanup.mjs | 72 +++++ .agents/skills/verify-synac/scripts/cli.mjs | 186 +++++++++++++ .../skills/verify-synac/scripts/doctor.mjs | 135 +++++++++ .../skills/verify-synac/scripts/drive.spec.ts | 257 ++++++++++++++++++ .../skills/verify-synac/scripts/evidence.ts | 13 + .../skills/verify-synac/scripts/launch.mjs | 216 +++++++++++++++ .../skills/verify-synac/scripts/runtime.mjs | 226 +++++++++++++++ .agents/skills/verify-synac/scripts/smoke.mjs | 63 +++++ .../skills/verify-synac/scripts/smoke.spec.ts | 26 ++ .cursor/skills/verify-synac | 1 + .github/workflows/verify-smoke.yml | 68 +++++ .gitignore | 3 +- AGENTS.md | 4 + package.json | 7 +- 39 files changed, 2283 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/verify-synac/SKILL.md create mode 100644 .agents/skills/verify-synac/playwright.config.mjs create mode 100644 .agents/skills/verify-synac/references/features/README.md create mode 100644 .agents/skills/verify-synac/references/features/about.md create mode 100644 .agents/skills/verify-synac/references/features/acronyms-browse.md create mode 100644 .agents/skills/verify-synac/references/features/changelog.md create mode 100644 .agents/skills/verify-synac/references/features/command-palette.md create mode 100644 .agents/skills/verify-synac/references/features/content-check.md create mode 100644 .agents/skills/verify-synac/references/features/content-compile.md create mode 100644 .agents/skills/verify-synac/references/features/content-history.md create mode 100644 .agents/skills/verify-synac/references/features/content-sync.md create mode 100644 .agents/skills/verify-synac/references/features/entry.md create mode 100644 .agents/skills/verify-synac/references/features/home.md create mode 100644 .agents/skills/verify-synac/references/features/ingest.md create mode 100644 .agents/skills/verify-synac/references/features/journeys.md create mode 100644 .agents/skills/verify-synac/references/features/legal.md create mode 100644 .agents/skills/verify-synac/references/features/not-found.md create mode 100644 .agents/skills/verify-synac/references/features/public-api.md create mode 100644 .agents/skills/verify-synac/references/features/recent.md create mode 100644 .agents/skills/verify-synac/references/features/search.md create mode 100644 .agents/skills/verify-synac/references/features/site-chrome.md create mode 100644 .agents/skills/verify-synac/references/features/sources.md create mode 100644 .agents/skills/verify-synac/references/features/tags.md create mode 100644 .agents/skills/verify-synac/references/features/terms-browse.md create mode 100644 .agents/skills/verify-synac/references/features/theme.md create mode 100755 .agents/skills/verify-synac/scripts/cleanup.mjs create mode 100755 .agents/skills/verify-synac/scripts/cli.mjs create mode 100755 .agents/skills/verify-synac/scripts/doctor.mjs create mode 100644 .agents/skills/verify-synac/scripts/drive.spec.ts create mode 100644 .agents/skills/verify-synac/scripts/evidence.ts create mode 100755 .agents/skills/verify-synac/scripts/launch.mjs create mode 100755 .agents/skills/verify-synac/scripts/runtime.mjs create mode 100755 .agents/skills/verify-synac/scripts/smoke.mjs create mode 100644 .agents/skills/verify-synac/scripts/smoke.spec.ts create mode 120000 .cursor/skills/verify-synac create mode 100644 .github/workflows/verify-smoke.yml diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md new file mode 100644 index 00000000..8904a3b1 --- /dev/null +++ b/.agents/skills/verify-synac/SKILL.md @@ -0,0 +1,242 @@ +--- +name: verify-synac +description: "Drive SynAc's content CLI and the public Next.js glossary. Use to launch the local stack, run the read-only doctor check, exercise CLI side effects and the site like a user, and capture command output, screenshots, and traces." +disable-model-invocation: true +--- + +# Verify SynAc + +SynAc is a public cybersecurity glossary. Readers search and browse terms, acronyms, tags, and sources in the Next.js app. Maintainers compile, check, sync, and ingest content with the CLI in `tools/content` and `tools/ingest`. There are no accounts. + +The feature map is [`references/features/README.md`](references/features/README.md). Open the one file that matches the change. Walk the README top to bottom for a broad sweep, and finish with `journeys.md`. + +A maintain pass (`maintain-verification-skill`) edits only this directory: `SKILL.md`, `references/features/`, `scripts/`, and `playwright.config.mjs`. Product code stays untouched. Outcomes are `clean`, `changed` (one PR), or `blocked`. The `verify:*` scripts in the root `package.json` are thin wrappers. Change the script bodies here, and leave those wrappers pointed at this directory. + +## Isolate + +This skill owns one stack: + +- Convex anonymous backend on `http://127.0.0.1:3210` (site port `3211`) +- Web app on `http://127.0.0.1:3000` + +A second checkout cannot bind those ports. Launch refuses to start when port 3000 or 3210 is already taken by a process this skill did not start. Doctor may read an existing server. Cleanup never stops it. + +Playwright uses a fresh browser context per test, so theme and clipboard stay inside that context. The Convex dataset is the synced repo content and is shared by every context on this machine. + +## Launch + +From the repo root, with Node 24 first on `PATH` (see `.node-version`): + +```bash +export PATH="${NVM_DIR:-$HOME/.nvm}/versions/node/$(nvm version 24)/bin:$PATH" +node -v +pnpm install --frozen-lockfile +pnpm verify:launch +``` + +`pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: + +1. Exit 2 when Node is older than 24. +2. If doctor is `READY`, print `REUSED` and exit 0. +3. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. +4. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. +5. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. +6. Start tmux session `synac-verify-convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Ready means the log line `Convex functions ready` in `/tmp/synac-verify/synac-verify-convex.log`. +7. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. +8. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. +9. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. +10. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web start -- -H 127.0.0.1 -p 3000`. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web dev` on the same host and port instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. + +`docs/contributing/local-dev.md` is the same stack, split across terminals. Use the helper so the sessions are recorded for cleanup. + +Logs and state: + +- `/tmp/synac-verify/run.json` lists sessions and listener pids this launch started +- `/tmp/synac-verify/synac-verify-convex.log` +- `/tmp/synac-verify/synac-verify-web.log` +- `/tmp/synac-verify/evidence/` is created at launch and kept by cleanup + +`.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. + +## Doctor + +Run this first whenever a drive looks wrong. It only sends GET requests. + +```bash +pnpm verify:doctor +``` + +`READY` (exit 0) means all of the following: + +- `GET /api/healthz` is 200 and `ok` is true +- `GET /` is 200 and the HTML contains `Glossary search` +- `GET /api/v1/search?q=soc` is 200 and `meta.total` is greater than 0 + +Anything else prints `NOT_READY` and exits 2. + +| Code | Meaning | +| --- | --- | +| `unreachable` | Nothing answered on the base URL | +| `empty-dataset` | `/api/healthz` is 503 or `ok` is false. The content backend did not answer | +| `home-unhealthy` | The home page is up without the glossary search landmark | +| `search-unreadable` | Home rendered, and the search response was not the expected JSON | +| `search-empty` | Health and home are fine, and `q=soc` has `meta.total` 0 | + +```bash +node .agents/skills/verify-synac/scripts/doctor.mjs --help +node .agents/skills/verify-synac/scripts/doctor.mjs --json +``` + +Doctor prints who is listening on port 3000. A screenshot taken while doctor is `NOT_READY` is not evidence. + +## Checkpoint + +`pnpm gate` is the full checkpoint. Run it once on the final head, after the fast group for the code you touched is green. The CI job timeout is 20 minutes. Stop and report the log if a local gate exceeds 20 minutes. Do not start a second gate while one is still running. + +While iterating, run one named group and stay inside its budget: + +| Group | Command | Budget | +| --- | --- | --- | +| `web` | `pnpm --filter @synac/web test` | 2 minutes | +| `content` | `pnpm --filter @synac/content-tools test` | 2 minutes | +| `ingest` | `pnpm --filter @synac/ingest-tools test` | 2 minutes | +| `convex` | `pnpm test:convex` | 3 minutes | +| `content-check` | `pnpm content:check` | 1 minute | +| `verify-smoke` | `pnpm verify:smoke` with the stack already up | 4 minutes | + +`pnpm gate` runs lint, convex lint, format, typecheck, convex typecheck, test, tagging check, content check, content history, and build. It does not launch the site and it does not run `verify:drive`. The end-to-end suite in `apps/e2e` stays `E2E_BASE_URL=http://127.0.0.1:3000 pnpm --filter @synac/e2e test:e2e` against this same stack. Use it when the change touches accessibility or a route the feature map does not list. + +## Drive + +Prefer the helpers in this skill. They use the accessible names already on the pages. + +Fast path (doctor, `content:check`, `content:compile` file write, home search `soc`, open the first result): + +```bash +pnpm verify:smoke +``` + +Feature sweep (every file under `references/features/`, including CLI side effects): + +```bash +pnpm verify:drive +``` + +Both commands run doctor first. If doctor exits 2, the browser does not open and the CLI drive does not run. + +One UI feature, after doctor is `READY`: + +```bash +E2E_BASE_URL=http://127.0.0.1:3000 \ +VERIFY_EVIDENCE_DIR=/tmp/synac-verify/evidence \ +node apps/e2e/node_modules/@playwright/test/cli.js test \ + --config .agents/skills/verify-synac/playwright.config.mjs \ + .agents/skills/verify-synac/scripts/drive.spec.ts \ + -g "command palette" +``` + +Run that from the repo root. The config file is `playwright.config.mjs` next to this skill. + +One CLI feature: + +```bash +node .agents/skills/verify-synac/scripts/cli.mjs --help +node .agents/skills/verify-synac/scripts/cli.mjs +``` + +Stable handles: + +- Home search: textbox `Search` inside the page (the header control named `Search` is a button and opens the palette). +- Palette: button `Search`, then combobox `Search terms and acronyms`. `Control+K` or `Meta+K` opens it. `/` opens it when focus is outside a text field. `Escape` closes it. `Enter` activates the highlighted option. +- Browse: navigation `Term letters` or `Acronym letters`, textbox `Filter entries`, combobox `Sort`. +- Tags: textbox `Filter tags`. +- Theme: button whose name starts with `Theme:`. The stored value is `localStorage['synac-theme']` and `document.documentElement.dataset.theme`. +- Entry: button `Copy link to this sense`, status text `Link copied`, summary `Cite`, link `Citation record (JSON)`. +- Mobile, below 920px: button `Open menu`, navigation `Mobile`. +- Header navigation `Primary`. Footer is `contentinfo`. + +Seeded slugs the corpus is known to contain (the same ones `apps/e2e/tests/helpers.ts` uses): + +- term `access-control` (heading `access control`) +- term `domain` +- acronym `black` (heading `BLACK`) +- acronym `2fa` (a `/term/2fa` visit redirects to `/acronym/2fa`) +- tag `identity-access` (heading `Identity and access`) +- source `rfc4949` +- query `soc` + +## Evidence + +Proof artifacts go to `/tmp/synac-verify/evidence/` (override with `VERIFY_EVIDENCE_DIR`). Cleanup never deletes that directory. + +- `cli-*.log` and `cli-report.json` from the CLI helper +- `cli-sync.log` from launch, with the line `tag convergence: verified` +- `*.png` screenshots taken by the specs +- `test-results/` Playwright traces (`trace.zip` per test, trace is `on`) +- `playwright-report/` HTML report +- `tools/content/dist/compiled.json` is the compile side effect inside the repo. `dist/` is gitignored. The proof is the file's `contentVersion` matching the `content ok:` line, plus `entries` length greater than 0 + +A proof exercises the user path: type in the search box, click a result, press the shortcut, run the CLI. Reading `/api/healthz` is the doctor check, and it is not the proof that search works. + +Capture the action and the end state. Examples: the search box filled, then the entry heading; `content:compile` run, then `compiled.json` on disk; sync log `tag convergence: verified`, then `GET /api/v1/search?q=soc` with `meta.total` greater than 0. + +Side effects worth reading: + +- URL, heading, and `aria-current` +- `localStorage['synac-theme']` and `data-theme` after a click and again after reload +- clipboard text after Copy link to this sense +- `tools/content/dist/compiled.json` after `pnpm content:compile` +- the isolated ingest directory `/tmp/synac-verify/ingest-content/generated/rfc4949.json` when the upstream fetch runs +- response status and JSON for the public read API, including a 304 on `If-None-Match` + +The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. + +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. If the fetch cannot finish, the helper records `INCONCLUSIVE` in `cli-report.json` and still requires the local rejection checks (no args, unknown source) to pass. + +## Cleanup + +```bash +pnpm verify:cleanup +``` + +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them, and it signals the listener pids recorded in that file. It does not kill processes by name. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. + +Confirm the evidence directory still exists after cleanup before citing it. + +## Helpers + +| Command | Script | +| --- | --- | +| `pnpm verify:launch` | `scripts/launch.mjs` | +| `pnpm verify:doctor` | `scripts/doctor.mjs` | +| `pnpm verify:smoke` | `scripts/smoke.mjs` (doctor, CLI smoke, `smoke.spec.ts`) | +| `pnpm verify:drive` | `scripts/smoke.mjs --drive` (doctor, full CLI, `drive.spec.ts`) | +| `pnpm verify:cleanup` | `scripts/cleanup.mjs` | + +```bash +node .agents/skills/verify-synac/scripts/launch.mjs --help +node .agents/skills/verify-synac/scripts/doctor.mjs --help +node .agents/skills/verify-synac/scripts/cli.mjs --help +node .agents/skills/verify-synac/scripts/smoke.mjs --help +node .agents/skills/verify-synac/scripts/cleanup.mjs --help +``` + +`scripts/evidence.ts` writes screenshots into `VERIFY_EVIDENCE_DIR`. + +## How to report + +Put a Proof section on the pull request: + +```markdown +### Proof +- Commands: the exact commands, including `pnpm verify:doctor` and the drive you ran +- Evidence: command logs, screenshot paths, and trace paths that still exist after cleanup +- Results: pass or fail per command, plus the observable end state (URL, heading, file written, record read back, attribute) +- INCONCLUSIVE: any step that could not run, and the concrete reason (port busy, upstream fetch failed, revalidate secret unset, browser library missing) +``` + +A missing check is `INCONCLUSIVE`. Do not describe it as passed. + +## Feature map + +[`references/features/README.md`](references/features/README.md) is the sweep order. Each feature file has the same four headings: Sub-features, How to get to it (user POV), Driving it with Playwright or Driving it with the CLI, Gotchas. diff --git a/.agents/skills/verify-synac/playwright.config.mjs b/.agents/skills/verify-synac/playwright.config.mjs new file mode 100644 index 00000000..a83753d5 --- /dev/null +++ b/.agents/skills/verify-synac/playwright.config.mjs @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const skillDir = path.dirname(fileURLToPath(import.meta.url)); +const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? '/tmp/synac-verify/evidence'; + +export default { + testDir: path.join(skillDir, 'scripts'), + outputDir: path.join(evidenceDir, 'test-results'), + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [ + ['line'], + ['html', { outputFolder: path.join(evidenceDir, 'playwright-report'), open: 'never' }], + ], + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000', + trace: 'on', + screenshot: 'on', + viewport: { width: 1280, height: 800 }, + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}; diff --git a/.agents/skills/verify-synac/references/features/README.md b/.agents/skills/verify-synac/references/features/README.md new file mode 100644 index 00000000..66f20784 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/README.md @@ -0,0 +1,77 @@ +# SynAc feature map + +Behavior-level inventory of the public glossary and the maintainer CLI. Agents use this map to decide what to drive and what evidence counts. The order below is the regression sweep. + +## Baseline preconditions + +- Node 24 on `PATH`. Run `pnpm verify:launch` and clear a `NOT_READY` doctor before driving the UI. +- Doctor `READY` means `/api/healthz` is ok, the home page has the glossary search landmark, and `GET /api/v1/search?q=soc` has `meta.total` greater than 0. +- One stack only: web on `127.0.0.1:3000`, Convex on `127.0.0.1:3210`. Launch refuses a second owner of those ports. +- Prefer accessible names (`Search`, `Theme: Dark`, `Term letters`, `Copy link to this sense`). The header `Search` control is a button. The page search field is a textbox with the same accessible name. +- Seeded slugs: term `access-control`, term `domain`, acronym `black`, redirect `2fa`, tag `identity-access`, source `rfc4949`, query `soc`. +- `pnpm gate` is the full checkpoint in `SKILL.md`. Run it once on the final head. Use the named fast group while iterating. + +## Proof and skip reporting + +The relevant feature file is the coverage set. When a change crosses search, an entry, and the CLI, also read `journeys.md`. + +- Exercise every reachable entry point the change can affect, including empty, error, and hostile inputs listed in Gotchas. +- Record the action and the final observable state, including files written and records read back. +- Mocks count only where the product already isolates an upstream fetch. The glossary reads the synced Convex dataset. Do not stub those reads. +- When a path is unreachable, name it and the concrete prerequisite. Live RFC ingest is `INCONCLUSIVE` when the upstream fetch cannot finish. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. + +## Full sweep + +Walk this list top to bottom. Finish with `journeys.md`. + +## Home and search + +- [home](home.md): Landing page, glossary search, recently updated. +- [search](search.md): Query, type filter, entries versus meanings, empty and ignored queries. +- [command-palette](command-palette.md): Header search button and keyboard palette. + +## Browse + +- [terms-browse](terms-browse.md): Term index, letters, filter, sort. +- [acronyms-browse](acronyms-browse.md): Acronym index. +- [tags](tags.md): Tag directory and a tag page. +- [sources](sources.md): Source index and a source page. +- [recent](recent.md): Recently updated index. + +## Entry + +- [entry](entry.md): Term and acronym pages, senses, copy link, citation record. +- [not-found](not-found.md): Unknown slug and the `2fa` redirect. + +## Chrome + +- [site-chrome](site-chrome.md): Header, footer, mobile menu. +- [theme](theme.md): System, dark, and light, kept in localStorage. +- [about](about.md): About page. +- [legal](legal.md): Privacy policy and terms of use. +- [changelog](changelog.md): Changelog page and RSS feed. + +## HTTP API + +- [public-api](public-api.md): Public read API under `/api/v1`. + +## Maintainer CLI + +- [content-check](content-check.md): `pnpm content:check`. +- [content-compile](content-compile.md): `pnpm content:compile` writes `compiled.json`. +- [content-history](content-history.md): `pnpm content:history`. +- [content-sync](content-sync.md): sync into the local Convex deployment. +- [ingest](ingest.md): regenerate one bundle from upstream. + +## Journeys + +- [journeys](journeys.md): Search to an entry, then a theme that survives navigation. + +## Entry contract + +Every feature file uses the same four H2s: + +1. `Sub-features` +2. `How to get to it (user POV)` +3. `Driving it with Playwright` or `Driving it with the CLI` +4. `Gotchas` diff --git a/.agents/skills/verify-synac/references/features/about.md b/.agents/skills/verify-synac/references/features/about.md new file mode 100644 index 00000000..b66085d9 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/about.md @@ -0,0 +1,22 @@ +# About + +A static explanation of how entries are structured and maintained. + +## Sub-features + +- page: `/about`, heading `About SynAc` + +## How to get to it (user POV) + +Header link `About`, footer link `About`, or the palette row `About`. + +## Driving it with Playwright + +`drive.spec.ts` test `about page`. + +Open `/about` and expect the heading `About SynAc`. Screenshot `about.png`. + +## Gotchas + +- The page is prose. A proof is the heading plus a visible paragraph, not a screenshot of the hero alone. +- There is no form and no write. An empty dataset does not blank this page. diff --git a/.agents/skills/verify-synac/references/features/acronyms-browse.md b/.agents/skills/verify-synac/references/features/acronyms-browse.md new file mode 100644 index 00000000..b72835b7 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/acronyms-browse.md @@ -0,0 +1,24 @@ +# Acronyms browse + +Alphabetical index of published acronyms. The controls match the term index. + +## Sub-features + +- letters: navigation `Acronym letters` +- filter, sort, tag chips, and paging: same parameters as terms, on `/acronyms` + +## How to get to it (user POV) + +Header link `Acronyms`, or `/acronyms`. The heading is `Acronyms`. + +## Driving it with Playwright + +`drive.spec.ts` test `acronyms browse opens BLACK`. + +Open `/acronyms`, click `B` in `Acronym letters`, then open `/acronym/black`. The heading is `BLACK`. Screenshot `acronym-black.png`. + +## Gotchas + +- Letter `B` is sorted by title. `BLACK` is near the start of that letter. If a future corpus pushes it past page 1, follow pagination `Next` until the link is visible and say so. +- Empty letters use the sentence `No published acronyms yet for `. +- A hostile `letter` value falls back to A, the same way the term index does. diff --git a/.agents/skills/verify-synac/references/features/changelog.md b/.agents/skills/verify-synac/references/features/changelog.md new file mode 100644 index 00000000..02b8f727 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/changelog.md @@ -0,0 +1,25 @@ +# Changelog + +Versioned notes, plus an RSS feed. + +## Sub-features + +- page: `/changelog`, heading `Changelog`, one section per version +- rss: link `RSS` to `/changelog/rss.xml` +- empty: `No changelog entries yet.` when the list is empty + +## How to get to it (user POV) + +Footer link `Changelog`, or the palette row `Changelog`. + +## Driving it with Playwright + +`drive.spec.ts` test `changelog page and rss feed`. + +Open `/changelog`, expect the heading, and expect the `RSS` link href `/changelog/rss.xml`. `GET /changelog/rss.xml` is 200 and the body contains ` "$dir/tags.json" +SYNAC_CONTENT_DIR="$dir" pnpm content:check +``` + +Exit is non-zero and the output contains `content check failed` or `invalid JSON`. Log: `cli-content-hostile.log`. + +## Gotchas + +- `SYNAC_CONTENT_DIR` redirects the read. Forget it and the command checks the real `content/` tree. +- Warnings print as `⚠` and still exit 0 when there are no errors. A proof of failure needs a non-zero exit. +- This command does not write `tools/content/dist/compiled.json`. That file is the compile feature. diff --git a/.agents/skills/verify-synac/references/features/content-compile.md b/.agents/skills/verify-synac/references/features/content-compile.md new file mode 100644 index 00000000..2b10e50f --- /dev/null +++ b/.agents/skills/verify-synac/references/features/content-compile.md @@ -0,0 +1,31 @@ +# Content compile + +Compiles `content/` and writes the dataset to disk. + +## Sub-features + +- emit: `pnpm content:compile` (the `check` script with `--emit`) +- file: `tools/content/dist/compiled.json` + +## How to get to it (user POV) + +A maintainer runs it when they need the compiled JSON locally. The web app does not read this file. Sync reads the compiler in process. + +## Driving it with the CLI + +`pnpm verify:smoke` runs compile after content check. + +```bash +pnpm content:compile +test -s tools/content/dist/compiled.json +``` + +Stdout contains `compiled dataset written to tools/content/dist/compiled.json`. The JSON has `contentVersion` starting with the 12 hex characters from the `content ok:` line, and `entries` is a non-empty array. Log: `cli-content-compile.log`. + +`dist/` is gitignored. Leave the file in the working tree. Do not commit it. + +## Gotchas + +- A failed check exits before the write. If the file already existed from an older run, a failed compile does not prove the new run wrote it. Compare `contentVersion` to the log line from the same run. +- The path is fixed. `SYNAC_CONTENT_DIR` changes the input directory and still writes `tools/content/dist/compiled.json` under the repo. +- Compile does not push to Convex. Search staying empty after compile means sync has not run. diff --git a/.agents/skills/verify-synac/references/features/content-history.md b/.agents/skills/verify-synac/references/features/content-history.md new file mode 100644 index 00000000..274fb837 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/content-history.md @@ -0,0 +1,28 @@ +# Content history + +Checks that `content/tag-assignments.json` history still chains from a git base ref. + +## Sub-features + +- unchanged: prints `tag assignment history ok:` when the file matches the base +- changed: validates `previousAssignmentsHash` when the file differs from the base + +## How to get to it (user POV) + +A maintainer runs `pnpm content:history` before a content PR. CI runs it inside `pnpm gate`. + +## Driving it with the CLI + +`pnpm verify:drive` runs it. Smoke skips it. + +```bash +pnpm content:history +``` + +Stdout contains `tag assignment history ok:` and the path `content/tag-assignments.json`. Log: `cli-content-history.log`. A non-zero exit fails the drive. + +## Gotchas + +- The default base is the merge base of `HEAD` and `origin/main`. When that ref is missing, the command falls back to `HEAD`, the diff is empty, and it still prints the ok line. That pass does not prove history against `origin/main`. +- `--base ` overrides the base. A missing value is an error: `--base requires a Git ref`. +- This command reads git. It does not write assignments. diff --git a/.agents/skills/verify-synac/references/features/content-sync.md b/.agents/skills/verify-synac/references/features/content-sync.md new file mode 100644 index 00000000..3ee8f25e --- /dev/null +++ b/.agents/skills/verify-synac/references/features/content-sync.md @@ -0,0 +1,32 @@ +# Content sync + +Pushes the compiled corpus into the local Convex deployment and checks tag counts. + +## Sub-features + +- push: `pnpm --filter @synac/content-tools sync` +- status: stdout `sync status:` and `tag convergence: verified` +- read-back: the site search returns seeded rows after sync + +## How to get to it (user POV) + +A maintainer runs sync after `npx convex dev` and `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. Readers then see the entries on the site. + +## Driving it with the CLI + +`pnpm verify:launch` runs sync once and copies stdout to `/tmp/synac-verify/evidence/cli-sync.log`. + +```bash +pnpm --filter @synac/content-tools sync +``` + +The log contains `tag convergence: verified` and `to the local dev deployment`. Then doctor `READY` is the read-back: `GET /api/v1/search?q=soc` has `meta.total` greater than 0, and `/term/access-control` renders. + +`--prod` targets a production deployment and needs `CONVEX_DEPLOY_KEY`. The verify skill does not pass `--prod`. A production sync is `INCONCLUSIVE` in this environment. + +## Gotchas + +- Sync against a Convex process this skill did not start is refused by launch. Do not point sync at a shared deployment to "save time". +- Re-running sync is idempotent. A second `tag convergence: verified` still counts. The proof is the line plus a search that returns data. +- If the service key is unset, sync's Convex calls fail. Launch sets `SYNAC_CONVEX_SERVICE_KEY=local-dev` before sync. +- The command writes records. It does not rewrite `content/generated/`. diff --git a/.agents/skills/verify-synac/references/features/entry.md b/.agents/skills/verify-synac/references/features/entry.md new file mode 100644 index 00000000..af94eba1 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/entry.md @@ -0,0 +1,30 @@ +# Entry + +A published term or acronym: title, senses, copy link, and citation. + +## Sub-features + +- title: heading level 1 is the entry title (`access control`, `BLACK`) +- senses: anchors `#sense-...`, table-of-contents links +- copy: button `Copy link to this sense`, status `Link copied` +- cite: summary `Cite`, then link `Citation record (JSON)` +- related: region `Related entries` when the entry has relationships + +## How to get to it (user POV) + +Open a search result, a browse row, or a direct URL such as `/term/access-control` or `/acronym/black`. + +## Driving it with Playwright + +`drive.spec.ts` test `entry page copy link and citation record`. + +Open `/term/access-control`. The heading is `access control`. Click the first `#sense-` link and expect that fragment in the URL. Click `Copy link to this sense` and expect the status `Link copied`. Read the clipboard. It contains `/term/access-control#`. Open `Cite`, follow `Citation record (JSON)`, and expect HTTP 200 whose body names `rfc4949`. Screenshot `entry-access-control.png`. + +The acronym path is the end of the acronyms browse test: heading `BLACK`. + +## Gotchas + +- Clipboard write needs a browser permission and a click. A headless context without `clipboard-read` and `clipboard-write` shows `Could not copy the link`. That failure is the copy path, not a missing button. +- A sense with no definition text says `No definition yet.` +- Citation JSON is a real route, `/api/v1/senses/citation.json`. Fetch the href the page rendered. Do not invent the sense key. +- Multi-sense terms such as `domain` show a numbered list. `access-control` may show a single sense. The copy button is still there. diff --git a/.agents/skills/verify-synac/references/features/home.md b/.agents/skills/verify-synac/references/features/home.md new file mode 100644 index 00000000..005aa99b --- /dev/null +++ b/.agents/skills/verify-synac/references/features/home.md @@ -0,0 +1,32 @@ +# Home + +The landing page: a glossary search box and a short list of recently updated entries. + +## Sub-features + +- hero-search: text box that submits to `/search` +- recent: up to eight recently updated entries, plus a link to the full list +- quick-links: terms, acronyms, and tags + +## How to get to it (user POV) + +Open `/`. The heading is `SynAc`. The search field is the large text box. Recently updated sits under it. + +## Driving it with Playwright + +```bash +pnpm verify:smoke +``` + +Or the home test inside `pnpm verify:drive`. + +Fill the textbox named `Search` with `soc` and press Enter. The URL becomes `/search?q=soc` and at least one result link is visible. The region named `Recently updated` contains entry links when the dataset is synced. + +Screenshot `smoke-home.png` shows the filled path's start. `home.png` is the drive shot. + +## Gotchas + +- The header control named `Search` is a button that opens the palette. The home field is a textbox with the same accessible name. +- An empty dataset renders `No published entries yet.` inside Recently updated. Doctor reports `empty-dataset` or `search-empty` before you treat that as the normal home. +- A one-character query and the stopwords `a`, `an`, `and`, `or`, `the` do not run a search. Drive those on the search page. +- A query longer than 120 characters is truncated. The form still submits. diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md new file mode 100644 index 00000000..a611e589 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -0,0 +1,55 @@ +# Ingest + +Regenerates `content/generated/.json` from an upstream document. + +## Sub-features + +- one source: `pnpm ingest -- --source rfc4949` +- all sources: `pnpm ingest -- --all` +- usage: no arguments exits 1 and prints `usage: ingest --source | --all` +- missing source: `--source not-a-source` prints `no source registry file` and exits 1 + +## How to get to it (user POV) + +A maintainer, or the weekly ingest workflow, regenerates a bundle and opens a PR when bytes change. Readers see the new definitions only after that bundle is merged and synced. + +## Driving it with the CLI + +`pnpm verify:drive` runs the local rejection checks and one isolated RFC fetch. + +Usage, from the repo root: + +```bash +pnpm --filter @synac/ingest-tools ingest +``` + +Exit 1. Stderr contains `usage: ingest`. + +Unknown source, against a scratch content dir: + +```bash +pnpm --filter @synac/ingest-tools ingest -- --source not-a-source +``` + +Exit 1. Output contains `no source registry file`. + +Live fetch, isolated so the repo bundle cannot change: + +```bash +dir=/tmp/synac-verify/ingest-content +mkdir -p "$dir/sources" "$dir/generated" +cp content/sources/rfc4949.json "$dir/sources/" +cp content/generated/rfc4949.json "$dir/generated/" +SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rfc4949 +``` + +A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. + +If the upstream fetch cannot finish, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The usage and missing-source checks still have to pass. + +## Gotchas + +- `--all` walks every source and will try the network for each. The sweep uses `rfc4949` only. +- A run with `SYNAC_CONTENT_DIR` unset writes `content/generated/` in the repo. The helper always sets the variable for the live fetch. +- `unchanged` means the new bundle compared equal to the previous file. Confirm the isolated file still exists. The word alone is not the side effect. +- Adapters that need a secret or a blocked host are `INCONCLUSIVE` with the error text. Do not vendor a fake RFC into product content to force a pass. diff --git a/.agents/skills/verify-synac/references/features/journeys.md b/.agents/skills/verify-synac/references/features/journeys.md new file mode 100644 index 00000000..565e0d3c --- /dev/null +++ b/.agents/skills/verify-synac/references/features/journeys.md @@ -0,0 +1,26 @@ +# Journeys + +Paths that cross more than one feature. Walk this file at the end of a broad sweep. + +## Sub-features + +- search-to-entry: home search `access control` opens `/term/access-control` +- theme-survives-navigation: dark theme set on the entry is still dark on `/` + +## How to get to it (user POV) + +Start at the home search box. Open `access control`. Change the theme. Go back home. + +## Driving it with Playwright + +`drive.spec.ts` test `journey from search to a saved theme`. + +Fill the home textbox `Search` with `access control` and press Enter. The URL is `/search` with that query. Open the link `/term/access-control`. The heading is `access control`. Click the theme button once. Visit `/`. `` is set and `localStorage['synac-theme']` is `dark`. Screenshot `journey-home-dark.png`. + +The CLI half of a cross-surface change is still `pnpm verify:drive`: compile writes `compiled.json`, and launch's sync log plus this page are the read-back of those records. + +## Gotchas + +- The search form encodes the space as `+` or `%20`. Accept either in the URL. +- Theme state is per browser context. This journey sets it inside one test. +- If `access control` is missing from search while doctor is `READY`, the corpus changed. Update the seeded slug in this file and in `drive.spec.ts` together, after you confirm the new slug in `content/`. diff --git a/.agents/skills/verify-synac/references/features/legal.md b/.agents/skills/verify-synac/references/features/legal.md new file mode 100644 index 00000000..43f1b569 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/legal.md @@ -0,0 +1,23 @@ +# Legal + +Privacy policy and terms of use. + +## Sub-features + +- privacy: `/legal/privacy`, heading `Privacy Policy` +- terms: `/legal/terms`, heading `Terms of Use` + +## How to get to it (user POV) + +Footer links `Privacy` and `Terms of Use`. + +## Driving it with Playwright + +`drive.spec.ts` test `legal privacy and terms`. + +Open both URLs and expect those headings. Screenshot `legal-terms.png` is the terms page. + +## Gotchas + +- These pages are static prose. They do not read the glossary dataset. +- The footer label is `Terms of Use`. The header label `Terms` is the glossary index, a different page. diff --git a/.agents/skills/verify-synac/references/features/not-found.md b/.agents/skills/verify-synac/references/features/not-found.md new file mode 100644 index 00000000..c58d81f7 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/not-found.md @@ -0,0 +1,27 @@ +# Not found and redirects + +Unknown URLs and the permanent redirect from a term URL to an acronym URL. + +## Sub-features + +- missing-page: heading `Page not found` +- term-to-acronym: `/term/2fa` responds 308 to `/acronym/2fa` +- missing-tag: `/tags/nonexistent` is the same not-found page + +## How to get to it (user POV) + +Follow a stale link, or type a slug that is not published. + +## Driving it with Playwright + +`drive.spec.ts` test `missing entry and acronym redirect`. + +`/term/this-slug-does-not-exist-9f3a` shows `Page not found`. Screenshot `not-found.png`. + +`/term/2fa` lands on `/acronym/2fa` with a level-1 heading. Playwright follows the redirect. A status check belongs on `request.get` with `maxRedirects: 0`, which returns 308 and a `location` containing `/acronym/2fa`. + +## Gotchas + +- The not-found copy mentions the palette shortcut. The page still offers home, terms, and acronyms links. +- A hostile slug with spaces or slashes is a different route, not this page. Use a single unknown path segment. +- The redirect is only for slugs that exist as the other entry type. An unknown slug stays 404. diff --git a/.agents/skills/verify-synac/references/features/public-api.md b/.agents/skills/verify-synac/references/features/public-api.md new file mode 100644 index 00000000..7c25280a --- /dev/null +++ b/.agents/skills/verify-synac/references/features/public-api.md @@ -0,0 +1,38 @@ +# Public API + +Unauthenticated read API. The pages and the API read the same synced generation. + +## Sub-features + +- health: `GET /api/healthz` (doctor uses this) +- search: `GET /api/v1/search` +- entry: `GET /api/v1/entries/by-slug` +- lists: `GET /api/v1/terms`, `/api/v1/acronyms`, `/api/v1/tags`, `/api/v1/sources`, `/api/v1/sources/rfc4949` +- citation: `GET /api/v1/senses/citation.json` +- export: `GET /api/v1/export/entries.json` +- document: `GET /api/v1/openapi.json` +- operator: `POST /api/v1/internal/revalidate` (secret required) +- reports: `POST /api/v1/csp-report` + +## How to get to it (user POV) + +Readers do not see a separate API screen. The search page, the palette, and the citation link on an entry call these routes. `docs/api.md` lists them for programmatic readers. + +## Driving it with Playwright + +`drive.spec.ts` test `public api search, etag, openapi, and export`, plus the citation fetch in the entry test. + +- `GET /api/v1/search?q=soc` is 200, `meta.total` greater than 0, and `results` is an array. +- The same URL with `If-None-Match` set to the first response's `etag` returns 304 and an empty body. +- `GET /api/v1/search?q=the` is 200 and `meta.total` is 0. +- `GET /api/v1/openapi.json` is 200 and `openapi` starts with `3.`. +- `GET /api/v1/entries/by-slug?type=TERM&slug=access-control` is 200 and `entry.slug` is `access-control`. +- The same route with slug `this-slug-does-not-exist-9f3a` is 404. +- `GET /api/v1/export/entries.json?page=1` is 200 and `results` is non-empty. + +## Gotchas + +- A refused query (empty, one character, stopword) is 200 with an empty `results` array. It is not a 400. +- Search is limited to 60 calls a minute per caller. A sweep that loops will start returning 429. Stay on the calls listed above. +- `POST /api/v1/internal/revalidate` stays dark unless `SYNAC_REVALIDATE_SECRET` is set and at least 16 characters. That path is `INCONCLUSIVE` without the secret. Do not send a guessed bearer token. +- Export page 1 returns real rows on `results`. Count that array. Do not download the whole corpus in a smoke run. diff --git a/.agents/skills/verify-synac/references/features/recent.md b/.agents/skills/verify-synac/references/features/recent.md new file mode 100644 index 00000000..4b72418c --- /dev/null +++ b/.agents/skills/verify-synac/references/features/recent.md @@ -0,0 +1,24 @@ +# Recent + +Published entries ordered by most recent update. + +## Sub-features + +- list: `/recent`, 50 rows +- paging: `page`, hard cap 4 + +## How to get to it (user POV) + +Home link `View all` under Recently updated, or the palette row `Recent updates`, or `/recent`. The heading is `Recently updated`. + +## Driving it with Playwright + +`drive.spec.ts` test `recent page paginates inside the cap`. + +`/recent` shows pagination text `Page 1`. `/recent?page=99` redirects to `/recent?page=4`. Screenshot `recent.png`. + +## Gotchas + +- Page numbers above 4 redirect to page 4. They do not 404. +- A non-numeric `page` is treated as page 1. +- The home block shows eight rows. The recent page shows the longer list. Both read the same recent query. diff --git a/.agents/skills/verify-synac/references/features/search.md b/.agents/skills/verify-synac/references/features/search.md new file mode 100644 index 00000000..e6cf905d --- /dev/null +++ b/.agents/skills/verify-synac/references/features/search.md @@ -0,0 +1,34 @@ +# Search + +Full-text search over published entries and meanings. + +## Sub-features + +- query: GET form, parameter `q` +- type-filter: All, Terms, Acronyms (`type=TERM` or `type=ACRONYM`) +- scope: Entries or Meanings (`scope=senses`) +- paging: `page`, capped at 10, page size 20 + +## How to get to it (user POV) + +Submit the home search box, or open `/search`. The heading is `Search`. With a real query, filters sit above the results. + +## Driving it with Playwright + +`drive.spec.ts` test `search empty, ignored, filtered, and hostile queries`. + +- `/search` shows `Search published entries`. +- `/search?q=the` shows `Try a more specific query`. +- `/search?q=soc&type=TERM&scope=senses` keeps Meanings as the current filter and lists result links. +- `/search?q=` still renders the Search heading. The query stays text in the box. + +Screenshot `search-empty.png` and `search-soc.png`. + +The matching read is `GET /api/v1/search?q=soc` with `meta.total` greater than 0. That is the same query the page runs. + +## Gotchas + +- Empty, one character, and the bare stopwords never call the search index. They are guidance, not "no matches". +- After 60 searches in a minute from one caller, the page says `Too many searches` and the API returns 429. Do not flood the bucket during a sweep. A later drive in the same minute can look empty because the limiter refused. +- `page=0`, `page=-1`, and `page=999` clamp into 1 through 10. There is no error page for a wild page number. +- Unknown `type` and `scope` values fall back to all entries. They do not 400. diff --git a/.agents/skills/verify-synac/references/features/site-chrome.md b/.agents/skills/verify-synac/references/features/site-chrome.md new file mode 100644 index 00000000..481b5a3a --- /dev/null +++ b/.agents/skills/verify-synac/references/features/site-chrome.md @@ -0,0 +1,25 @@ +# Site chrome + +The header, footer, and small-screen menu that wrap every page. + +## Sub-features + +- header: brand `SynAc` to `/`, navigation `Primary` with Terms, Acronyms, Tags, Sources, About +- footer: contentinfo links About, Privacy, Terms of Use, Changelog, GitHub +- mobile: below 920px the primary nav hides and button `Open menu` opens navigation `Mobile` + +## How to get to it (user POV) + +The chrome is on every page. Narrow the window to use the menu. + +## Driving it with Playwright + +`drive.spec.ts` test `header, footer, and mobile menu`. + +At 1280px, navigation `Primary` shows `Terms`, and the footer link `Privacy` is visible. Resize to 390 by 844, click `Open menu`, and follow `Tags` in navigation `Mobile`. The URL ends in `/tags`. Screenshot `mobile-tags.png`. + +## Gotchas + +- The menu button is `display: none` above 920px. A click at desktop width does not count. +- Escape closes the mobile panel. The drive uses a link click, which also closes it. +- The GitHub footer link leaves the site. Do not follow it in the sweep. diff --git a/.agents/skills/verify-synac/references/features/sources.md b/.agents/skills/verify-synac/references/features/sources.md new file mode 100644 index 00000000..b7183f28 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/sources.md @@ -0,0 +1,27 @@ +# Sources + +The registry of cited sources, with license facts on each source page. + +## Sub-features + +- index: `/sources`, one link per source +- detail: `/sources/rfc4949`, region `Source facts` (base URL, verified date, license, trust) +- cited entries: list on the source page, paged + +## How to get to it (user POV) + +Header link `Sources`. Open a source name. RFC 4949 is the seeded source. + +## Driving it with Playwright + +`drive.spec.ts` test `sources list opens RFC 4949`. + +Open `/sources`, click the link whose name contains `RFC 4949`, and expect `/sources/rfc4949` plus the region `Source facts`. Screenshot `source-rfc4949.png`. + +The read-back for the same source is `GET /api/v1/sources/rfc4949` (covered with the public API sweep). + +## Gotchas + +- An empty registry says `No sources yet.` Doctor `search-empty` is the signal that sync did not load the corpus. Do not treat that empty state as a content bug until doctor is `READY`. +- An unknown source slug 404s. +- The source title contains punctuation from the registry. Match the link with `/RFC 4949/` rather than a hand-copied glyph. diff --git a/.agents/skills/verify-synac/references/features/tags.md b/.agents/skills/verify-synac/references/features/tags.md new file mode 100644 index 00000000..4ba6331a --- /dev/null +++ b/.agents/skills/verify-synac/references/features/tags.md @@ -0,0 +1,27 @@ +# Tags + +Curated taxonomy directory and one page per tag. + +## Sub-features + +- directory: `/tags`, textbox `Filter tags`, client-side filter +- tag page: `/tags/identity-access`, heading `Identity and access` +- type chips: All, Terms, Acronyms on the tag page +- retired tags: a retired slug redirects or explains that the tag is retired + +## How to get to it (user POV) + +Header link `Tags`. Type in `Filter tags`. Open a tag name. + +## Driving it with Playwright + +`drive.spec.ts` test `tags filter, empty filter, and a tag page`. + +On `/tags`, fill `zzzz-no-such-tag` and expect `No matching tags.` Then fill `identity` and open `Identity and access`. The URL is `/tags/identity-access` and the heading matches the tag name. Screenshot `tag-identity-access.png`. + +## Gotchas + +- The directory filter is local. It does not change the URL and it does not call the API. +- An unknown tag slug returns 404 with the page-not-found heading. Drive that from `not-found.md` when the change touches routing. +- A tag with zero published entries says `No published entries yet for this tag.` +- Filtering is a substring over name, slug, and description. `identity` matches `Identity and access`. diff --git a/.agents/skills/verify-synac/references/features/terms-browse.md b/.agents/skills/verify-synac/references/features/terms-browse.md new file mode 100644 index 00000000..042cf192 --- /dev/null +++ b/.agents/skills/verify-synac/references/features/terms-browse.md @@ -0,0 +1,30 @@ +# Terms browse + +Alphabetical index of published terms. + +## Sub-features + +- letters: A through Z and `0-9`, navigation `Term letters` +- filter: textbox `Filter entries`, query parameter `q`, debounced +- sort: combobox `Sort`, `title` (default) or `updated` +- tag chips: navigation `Tag filter` +- paging: 50 rows, page capped at 10 + +## How to get to it (user POV) + +Header link `Terms`, or `/terms`. The heading is `Terms`. + +## Driving it with Playwright + +`drive.spec.ts` test `terms browse letter, sort, filter, and a rejected letter`. + +Open `/terms?letter=d`. The `D` link is current and `/term/domain` is in the list. Choose `Recently updated` in `Sort` and expect `sort=updated` in the URL. Type `access` into `Filter entries` and expect `q=access`. Screenshot `terms-d.png`. + +`/terms?letter=DROP%20TABLE` falls back to letter A, which is current. + +## Gotchas + +- An unknown letter, including a hostile string, is served as `a`. The page stays 200. +- A letter with no rows says `No published terms yet for `. Assert that sentence when the list is empty. Do not invent rows. +- The filter waits about 260ms before it changes the URL. Wait on the URL, not a fixed sleep alone. +- Page numbers above 10 are clamped. The UI does not offer a page the backend will not serve. diff --git a/.agents/skills/verify-synac/references/features/theme.md b/.agents/skills/verify-synac/references/features/theme.md new file mode 100644 index 00000000..99b2f94f --- /dev/null +++ b/.agents/skills/verify-synac/references/features/theme.md @@ -0,0 +1,25 @@ +# Theme + +Cycles system, dark, and light. The choice is stored in the browser. + +## Sub-features + +- cycle: button name `Theme: System`, then `Theme: Dark`, then `Theme: Light`, then back to system +- persist: `localStorage['synac-theme']` and `data-theme` on `` +- system: no `data-theme` attribute, so the OS preference applies + +## How to get to it (user POV) + +The theme button sits in the header on every page. + +## Driving it with Playwright + +`drive.spec.ts` test `theme cycles and survives reload`. + +A fresh context starts at `Theme: System`. Click once. The name is `Theme: Dark`, `` is set, and `localStorage['synac-theme']` is `dark`. Reload. The attribute is still `dark`. Screenshot `theme-dark.png`. + +## Gotchas + +- System removes `data-theme`. Assert the attribute is absent for that state. A missing attribute is the success state. +- Storage that throws still applies the theme for the current view. The reload check is what proves persistence. +- Each Playwright test has its own context. A theme set in one test is not visible in the next. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs new file mode 100755 index 00000000..224a78aa --- /dev/null +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs'; + +import { + evidenceDir, + ownedSessions, + readRun, + sessionAlive, + tmux, + writeRun, +} from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs + +Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) +and the listener pids recorded in /tmp/synac-verify/run.json. + +Leaves /tmp/synac-verify/evidence/ in place. +`; + +function childPids(pid) { + const file = `/proc/${pid}/task/${pid}/children`; + if (!existsSync(file)) return []; + return readFileSync(file, 'utf8') + .trim() + .split(/\s+/) + .map((part) => Number(part)) + .filter((child) => Number.isInteger(child) && child > 1); +} + +function killTree(pid) { + if (!Number.isInteger(pid) || pid <= 1) return; + for (const child of childPids(pid)) killTree(child); + try { + process.kill(pid, 'SIGTERM'); + } catch { + return; + } +} + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + const run = readRun(); + const sessions = run.sessions.filter((name) => ownedSessions.has(name)); + for (const name of sessions) { + if (!sessionAlive(name)) continue; + const result = tmux(['kill-session', '-t', name], { stdio: 'inherit' }); + if (result.status !== 0) { + console.error(`cleanup: tmux kill-session ${name} exited ${result.status}`); + } else { + console.log(`stopped session ${name}`); + } + } + + const recorded = [run.pids.convex, run.pids.web, run.pids.convexSite].filter( + (pid) => Number.isInteger(pid), + ); + for (const pid of recorded) killTree(pid); + if (recorded.length > 0) console.log(`signaled pids ${recorded.join(', ')}`); + + writeRun({ + ...run, + sessions: [], + pids: {}, + }); + console.log(`evidence ${evidenceDir}`); +} + +main(); diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs new file mode 100755 index 00000000..e5eda459 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import { evidenceDir, ensureState, root, runLogged, stateDir } from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/cli.mjs [--smoke] + +Runs the content CLI and checks side effects. + +--smoke content:check and content:compile (writes tools/content/dist/compiled.json) +default smoke, plus content:history, a hostile content dir, ingest usage, + and an isolated rfc4949 ingest. Live upstream fetch that cannot run + is recorded as INCONCLUSIVE and does not fail the local checks. +`; + +const compiledPath = path.join(root, 'tools/content/dist/compiled.json'); + +function sha256(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function requireOk(step, result, needle) { + if (result.status !== 0) { + throw new Error(`${step} exited ${result.status}. See ${result.logPath}`); + } + if (needle && !`${result.stdout}\n${result.stderr}`.includes(needle)) { + throw new Error(`${step} output missing ${JSON.stringify(needle)}. See ${result.logPath}`); + } + return result; +} + +function smokeSteps(report) { + const check = requireOk( + 'content:check', + runLogged('pnpm', ['content:check'], 'cli-content-check.log'), + 'content ok:', + ); + const versionMatch = check.stdout.match(/\(version ([0-9a-f]{12})\)/); + const version = versionMatch?.[1]; + if (!version) throw new Error('content:check did not print a content version'); + + requireOk( + 'content:compile', + runLogged('pnpm', ['content:compile'], 'cli-content-compile.log'), + 'compiled dataset written to tools/content/dist/compiled.json', + ); + const compiled = JSON.parse(readFileSync(compiledPath, 'utf8')); + if (typeof compiled.contentVersion !== 'string' || !compiled.contentVersion.startsWith(version)) { + throw new Error('compiled.json contentVersion does not match content:check'); + } + if (!Array.isArray(compiled.entries) || compiled.entries.length === 0) { + throw new Error('compiled.json has no entries'); + } + report.steps.push({ + name: 'content-check-and-compile', + status: 'pass', + version, + entries: compiled.entries.length, + file: compiledPath, + }); +} + +function historyStep(report) { + const result = requireOk( + 'content:history', + runLogged('pnpm', ['content:history'], 'cli-content-history.log'), + 'tag assignment history ok:', + ); + report.steps.push({ name: 'content-history', status: 'pass', log: result.logPath }); +} + +function hostileStep(report) { + const dir = path.join(stateDir, 'hostile-content'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'tags.json'), '{'); + const bad = runLogged('pnpm', ['content:check'], 'cli-content-hostile.log', { + env: { SYNAC_CONTENT_DIR: dir }, + }); + if (bad.status === 0) { + throw new Error('content:check accepted a hostile content dir'); + } + const text = `${bad.stdout}\n${bad.stderr}`; + if (!text.includes('content check failed') && !text.includes('invalid JSON')) { + throw new Error(`hostile content:check did not report a schema failure. See ${bad.logPath}`); + } + report.steps.push({ name: 'content-hostile', status: 'pass', log: bad.logPath }); + + const usage = runLogged( + 'pnpm', + ['--filter', '@synac/ingest-tools', 'ingest'], + 'cli-ingest-usage.log', + ); + const usageText = `${usage.stdout}\n${usage.stderr}`; + if (usage.status === 0 || !usageText.includes('usage: ingest')) { + throw new Error(`ingest with no args did not print usage. See ${usage.logPath}`); + } + report.steps.push({ name: 'ingest-usage', status: 'pass', log: usage.logPath }); + + const missing = runLogged( + 'pnpm', + ['--filter', '@synac/ingest-tools', 'ingest', '--', '--source', 'not-a-source'], + 'cli-ingest-missing.log', + { env: { SYNAC_CONTENT_DIR: dir } }, + ); + const missingText = `${missing.stdout}\n${missing.stderr}`; + if (missing.status === 0 || !missingText.includes('no source registry file')) { + throw new Error(`missing ingest source was not rejected. See ${missing.logPath}`); + } + report.steps.push({ name: 'ingest-missing-source', status: 'pass', log: missing.logPath }); +} + +function ingestStep(report) { + const dir = path.join(stateDir, 'ingest-content'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(path.join(dir, 'sources'), { recursive: true }); + mkdirSync(path.join(dir, 'generated'), { recursive: true }); + const source = path.join(root, 'content/sources/rfc4949.json'); + const bundle = path.join(root, 'content/generated/rfc4949.json'); + cpSync(source, path.join(dir, 'sources/rfc4949.json')); + cpSync(bundle, path.join(dir, 'generated/rfc4949.json')); + const before = sha256(bundle); + + const result = runLogged( + 'pnpm', + ['--filter', '@synac/ingest-tools', 'ingest', '--', '--source', 'rfc4949'], + 'cli-ingest-rfc4949.log', + { env: { SYNAC_CONTENT_DIR: dir }, timeoutMs: 180_000 }, + ); + const after = sha256(bundle); + if (before !== after) { + throw new Error('isolated ingest modified content/generated/rfc4949.json in the repo'); + } + const text = `${result.stdout}\n${result.stderr}`; + const wrote = existsSync(path.join(dir, 'generated/rfc4949.json')); + if (result.status === 0 && wrote && /rfc4949: (unchanged|updated|skipped)/.test(text)) { + report.steps.push({ + name: 'ingest-rfc4949', + status: 'pass', + log: result.logPath, + isolatedDir: dir, + }); + return; + } + report.steps.push({ + name: 'ingest-rfc4949', + status: 'INCONCLUSIVE', + reason: + 'live upstream fetch did not finish in this environment. Local ingest rejection checks already passed. See cli-ingest-rfc4949.log', + log: result.logPath, + }); + console.log('INCONCLUSIVE ingest rfc4949: upstream fetch did not finish'); +} + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + ensureState(); + const smoke = process.argv.includes('--smoke'); + const report = { steps: [] }; + try { + smokeSteps(report); + if (!smoke) { + historyStep(report); + hostileStep(report); + ingestStep(report); + } + } catch (error) { + report.steps.push({ + name: 'failed', + status: 'fail', + reason: error instanceof Error ? error.message : String(error), + }); + writeFileSync(path.join(evidenceDir, 'cli-report.json'), `${JSON.stringify(report, null, 2)}\n`); + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + writeFileSync(path.join(evidenceDir, 'cli-report.json'), `${JSON.stringify(report, null, 2)}\n`); + console.log(`cli report ${path.join(evidenceDir, 'cli-report.json')}`); +} + +main(); diff --git a/.agents/skills/verify-synac/scripts/doctor.mjs b/.agents/skills/verify-synac/scripts/doctor.mjs new file mode 100755 index 00000000..26805e78 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/doctor.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { baseUrl, portOwner, webPort } from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/doctor.mjs [--json] [--base-url URL] + +Read-only check that the local SynAc site is worth driving. + +READY (exit 0) requires all of the following: + GET /api/healthz is 200 and {"ok":true} + GET / is 200 and the HTML contains the glossary search landmark + GET /api/v1/search?q=soc is 200 and meta.total is greater than 0 + +Anything else prints NOT_READY and exits 2. +`; + +function argValue(name) { + const index = process.argv.indexOf(name); + if (index < 0) return undefined; + return process.argv[index + 1]; +} + +export async function runDoctor(url = baseUrl) { + const report = { + status: 'NOT_READY', + code: 'unreachable', + baseUrl: url, + portOwner: portOwner(webPort), + healthz: null, + home: null, + search: null, + }; + + let health; + let home; + let search; + try { + health = await fetch(`${url}/api/healthz`, { redirect: 'manual' }); + home = await fetch(`${url}/`, { redirect: 'manual' }); + search = await fetch(`${url}/api/v1/search?q=soc`, { redirect: 'manual' }); + } catch (error) { + report.detail = error instanceof Error ? error.message : String(error); + return report; + } + + const healthText = await health.text(); + const homeText = await home.text(); + let healthBody = null; + try { + healthBody = JSON.parse(healthText); + } catch { + healthBody = null; + } + let searchBody = null; + try { + searchBody = JSON.parse(await search.text()); + } catch { + searchBody = null; + } + + const total = + searchBody && + typeof searchBody === 'object' && + searchBody.meta && + typeof searchBody.meta.total === 'number' + ? searchBody.meta.total + : null; + + report.healthz = { status: health.status, ok: healthBody?.ok === true }; + report.home = { + status: home.status, + hasSearch: homeText.includes('Glossary search'), + }; + report.search = { status: search.status, total }; + + if (health.status === 503 || healthBody?.ok === false) { + report.code = 'empty-dataset'; + return report; + } + if (home.status !== 200 || !homeText.includes('Glossary search')) { + report.code = 'home-unhealthy'; + return report; + } + if (!searchBody || search.status !== 200) { + report.code = 'search-unreadable'; + return report; + } + if (total === 0) { + report.code = 'search-empty'; + return report; + } + if (typeof total !== 'number') { + report.code = 'search-unreadable'; + return report; + } + + report.status = 'READY'; + report.code = 'ready'; + return report; +} + +function printHuman(report) { + console.log(`${report.status} ${report.code}`); + console.log(`base ${report.baseUrl}`); + console.log(`port ${webPort} owner: ${report.portOwner}`); + if (report.detail) console.log(`detail ${report.detail}`); + if (report.healthz) { + console.log(`healthz ${report.healthz.status} ok=${report.healthz.ok}`); + } + if (report.home) { + console.log(`home ${report.home.status} glossary-search=${report.home.hasSearch}`); + } + if (report.search) { + console.log(`search ${report.search.status} total=${report.search.total ?? 'none'}`); + } +} + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + const url = argValue('--base-url') ?? baseUrl; + const report = await runDoctor(url); + if (process.argv.includes('--json')) { + console.log(JSON.stringify(report, null, 2)); + } else { + printHuman(report); + } + process.exit(report.status === 'READY' ? 0 : 2); +} + +const invoked = + process.argv[1] && + (process.argv[1].endsWith('/doctor.mjs') || process.argv[1].endsWith('doctor.mjs')); +if (invoked) await main(); diff --git a/.agents/skills/verify-synac/scripts/drive.spec.ts b/.agents/skills/verify-synac/scripts/drive.spec.ts new file mode 100644 index 00000000..47030e33 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/drive.spec.ts @@ -0,0 +1,257 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { capture } from './evidence'; + +async function shot(page: Page, name: string) { + await capture(page, name); +} + +test('home lists recent entries', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1, name: 'SynAc' })).toBeVisible(); + const recent = page.getByRole('region', { name: 'Recently updated' }); + await expect(recent.getByRole('link').first()).toBeVisible(); + await shot(page, 'home.png'); +}); + +test('search empty, ignored, filtered, and hostile queries', async ({ page }) => { + await page.goto('/search'); + await expect(page.getByText('Search published entries')).toBeVisible(); + await shot(page, 'search-empty.png'); + + await page.goto('/search?q=the'); + await expect(page.getByText('Try a more specific query')).toBeVisible(); + + await page.goto('/search?q=soc&type=TERM&scope=senses'); + await expect(page.getByRole('heading', { level: 1, name: 'Search' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Meanings' })).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('a[href*="/term/"], a[href*="/acronym/"]').first()).toBeVisible(); + await shot(page, 'search-soc.png'); + + await page.goto('/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'); + await expect(page.getByRole('heading', { level: 1, name: 'Search' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Search' })).toHaveValue( + '', + ); +}); + +test('command palette opens from the keyboard and navigates', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Search' }).waitFor(); + const input = page.getByRole('combobox', { name: 'Search terms and acronyms' }); + await expect(async () => { + await page.keyboard.press('Control+KeyK'); + await expect(input).toBeVisible({ timeout: 500 }); + }).toPass({ timeout: 10_000 }); + await input.fill('soc'); + await expect(page.getByRole('option').first()).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/(search|term|acronym)/); + await shot(page, 'palette.png'); +}); + +test('terms browse letter, sort, filter, and a rejected letter', async ({ page }) => { + await page.goto('/terms?letter=d'); + await expect(page.getByRole('heading', { level: 1, name: 'Terms' })).toBeVisible(); + const letters = page.getByRole('navigation', { name: 'Term letters' }); + await expect(letters.getByRole('link', { name: 'D', exact: true })).toHaveAttribute( + 'aria-current', + 'true', + ); + await expect(page.locator('a[href="/term/domain"]')).toBeVisible(); + await shot(page, 'terms-d.png'); + + await page.getByRole('combobox', { name: 'Sort' }).selectOption('updated'); + await expect(page).toHaveURL(/sort=updated/); + + const filter = page.getByRole('textbox', { name: 'Filter entries' }); + await filter.fill('access'); + await expect(page).toHaveURL(/q=access/, { timeout: 10_000 }); + + await page.goto('/terms?letter=DROP%20TABLE'); + await expect(letters.getByRole('link', { name: 'A', exact: true })).toHaveAttribute( + 'aria-current', + 'true', + ); +}); + +test('acronyms browse opens BLACK', async ({ page }) => { + await page.goto('/acronyms'); + await expect(page.getByRole('heading', { level: 1, name: 'Acronyms' })).toBeVisible(); + await page.getByRole('navigation', { name: 'Acronym letters' }).getByRole('link', { name: 'B', exact: true }).click(); + await expect(page).toHaveURL(/letter=b/); + await page.locator('a[href="/acronym/black"]').click(); + await expect(page.getByRole('heading', { level: 1, name: 'BLACK' })).toBeVisible(); + await shot(page, 'acronym-black.png'); +}); + +test('entry page copy link and citation record', async ({ page, context, request }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/term/access-control'); + await expect(page.getByRole('heading', { level: 1, name: 'access control' })).toBeVisible(); + const toc = page.locator('a[href^="#sense-"]').first(); + await expect(toc).toBeVisible(); + const fragment = await toc.getAttribute('href'); + expect(fragment).toBeTruthy(); + await toc.click(); + await expect(page).toHaveURL(new RegExp(`${fragment}$`)); + + await page.getByRole('button', { name: 'Copy link to this sense' }).first().click(); + await expect(page.getByRole('status').first()).toHaveText('Link copied'); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied).toContain('/term/access-control#'); + await shot(page, 'entry-access-control.png'); + + await page.getByText('Cite', { exact: true }).first().click(); + const jsonLink = page.getByRole('link', { name: 'Citation record (JSON)' }).first(); + await expect(jsonLink).toBeVisible(); + const href = await jsonLink.getAttribute('href'); + expect(href).toBeTruthy(); + const response = await request.get(href ?? ''); + expect(response.status()).toBe(200); + const body = (await response.json()) as { sourceSlug?: string }; + expect(JSON.stringify(body)).toContain('rfc4949'); +}); + +test('tags filter, empty filter, and a tag page', async ({ page }) => { + await page.goto('/tags'); + await expect(page.getByRole('heading', { level: 1, name: 'Tags' })).toBeVisible(); + const filter = page.getByRole('textbox', { name: 'Filter tags' }); + await filter.fill('zzzz-no-such-tag'); + await expect(page.getByText('No matching tags.')).toBeVisible(); + await filter.fill('identity'); + await page.getByRole('link', { name: 'Identity and access' }).click(); + await expect(page).toHaveURL(/\/tags\/identity-access$/); + await expect(page.getByRole('heading', { level: 1, name: 'Identity and access' })).toBeVisible(); + await shot(page, 'tag-identity-access.png'); +}); + +test('sources list opens RFC 4949', async ({ page }) => { + await page.goto('/sources'); + await expect(page.getByRole('heading', { level: 1, name: 'Sources' })).toBeVisible(); + await page.getByRole('link', { name: /RFC 4949/ }).click(); + await expect(page).toHaveURL(/\/sources\/rfc4949$/); + await expect(page.getByRole('region', { name: 'Source facts' })).toBeVisible(); + await shot(page, 'source-rfc4949.png'); +}); + +test('recent page paginates inside the cap', async ({ page }) => { + await page.goto('/recent'); + await expect(page.getByRole('heading', { level: 1, name: 'Recently updated' })).toBeVisible(); + await expect(page.getByRole('navigation', { name: 'Pagination' })).toContainText('Page 1'); + await page.goto('/recent?page=99'); + await expect(page).toHaveURL(/\/recent\?page=4/); + await shot(page, 'recent.png'); +}); + +test('changelog page and rss feed', async ({ page, request }) => { + await page.goto('/changelog'); + await expect(page.getByRole('heading', { level: 1, name: 'Changelog' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'RSS' })).toHaveAttribute('href', '/changelog/rss.xml'); + const rss = await request.get('/changelog/rss.xml'); + expect(rss.status()).toBe(200); + expect(await rss.text()).toContain(' { + await page.goto('/about'); + await expect(page.getByRole('heading', { level: 1, name: 'About SynAc' })).toBeVisible(); + await shot(page, 'about.png'); +}); + +test('legal privacy and terms', async ({ page }) => { + await page.goto('/legal/privacy'); + await expect(page.getByRole('heading', { level: 1, name: 'Privacy Policy' })).toBeVisible(); + await page.goto('/legal/terms'); + await expect(page.getByRole('heading', { level: 1, name: 'Terms of Use' })).toBeVisible(); + await shot(page, 'legal-terms.png'); +}); + +test('theme cycles and survives reload', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: /Theme:/ }); + await expect(toggle).toHaveAttribute('aria-label', 'Theme: System'); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-label', 'Theme: Dark'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + const stored = await page.evaluate(() => window.localStorage.getItem('synac-theme')); + expect(stored).toBe('dark'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await shot(page, 'theme-dark.png'); +}); + +test('header, footer, and mobile menu', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Terms' })).toBeVisible(); + await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Privacy' })).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('navigation', { name: 'Mobile' }).getByRole('link', { name: 'Tags' }).click(); + await expect(page).toHaveURL(/\/tags$/); + await shot(page, 'mobile-tags.png'); +}); + +test('missing entry and acronym redirect', async ({ page }) => { + await page.goto('/term/this-slug-does-not-exist-9f3a'); + await expect(page.getByRole('heading', { level: 1, name: 'Page not found' })).toBeVisible(); + await shot(page, 'not-found.png'); + await page.goto('/term/2fa'); + await expect(page).toHaveURL(/\/acronym\/2fa$/); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); +}); + +test('public api search, etag, openapi, and export', async ({ request }) => { + const search = await request.get('/api/v1/search?q=soc'); + expect(search.status()).toBe(200); + const body = (await search.json()) as { meta?: { total?: number }; results?: unknown[] }; + expect(body.meta?.total ?? 0).toBeGreaterThan(0); + expect(Array.isArray(body.results)).toBe(true); + + const ignored = await request.get('/api/v1/search?q=the'); + expect(ignored.status()).toBe(200); + const ignoredBody = (await ignored.json()) as { meta?: { total?: number } }; + expect(ignoredBody.meta?.total).toBe(0); + + const etag = search.headers()['etag'] ?? ''; + expect(etag).toBeTruthy(); + const cached = await request.get('/api/v1/search?q=soc', { + headers: { 'if-none-match': etag }, + }); + expect(cached.status()).toBe(304); + + const spec = await request.get('/api/v1/openapi.json'); + expect(spec.status()).toBe(200); + const specBody = (await spec.json()) as { openapi?: string }; + expect(specBody.openapi).toMatch(/^3\./); + + const entry = await request.get('/api/v1/entries/by-slug?type=TERM&slug=access-control'); + expect(entry.status()).toBe(200); + const entryBody = (await entry.json()) as { entry?: { slug?: string } }; + expect(entryBody.entry?.slug).toBe('access-control'); + + const missing = await request.get('/api/v1/entries/by-slug?type=TERM&slug=this-slug-does-not-exist-9f3a'); + expect(missing.status()).toBe(404); + + const exported = await request.get('/api/v1/export/entries.json?page=1'); + expect(exported.status()).toBe(200); + const exportBody = (await exported.json()) as { results?: unknown[] }; + expect((exportBody.results ?? []).length).toBeGreaterThan(0); +}); + +test('journey from search to a saved theme', async ({ page }) => { + await page.goto('/'); + const search = page.getByRole('textbox', { name: 'Search' }); + await search.fill('access control'); + await search.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=access\+control|\/search\?q=access%20control/); + await page.locator('a[href="/term/access-control"]').first().click(); + await expect(page.getByRole('heading', { level: 1, name: 'access control' })).toBeVisible(); + await page.getByRole('button', { name: /Theme:/ }).click(); + await page.goto('/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + const stored = await page.evaluate(() => window.localStorage.getItem('synac-theme')); + expect(stored).toBe('dark'); + await shot(page, 'journey-home-dark.png'); +}); diff --git a/.agents/skills/verify-synac/scripts/evidence.ts b/.agents/skills/verify-synac/scripts/evidence.ts new file mode 100644 index 00000000..e7be6a96 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/evidence.ts @@ -0,0 +1,13 @@ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; + +import type { Page } from '@playwright/test'; + +export const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? '/tmp/synac-verify/evidence'; + +export async function capture(page: Page, name: string): Promise { + mkdirSync(evidenceDir, { recursive: true }); + const file = path.join(evidenceDir, name.endsWith('.png') ? name : `${name}.png`); + await page.screenshot({ path: file, fullPage: true }); + return file; +} diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs new file mode 100755 index 00000000..e73d57b0 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import { runDoctor } from './doctor.mjs'; +import { + baseUrl, + convexPort, + convexSitePort, + convexUrl, + ensureState, + evidenceDir, + listenerPids, + nodeMajor, + portOpen, + readRun, + root, + runLogged, + sessionAlive, + sessionConvex, + sessionWeb, + shQuote, + startSession, + stateDir, + waitFor, + webPort, + writeRun, +} from './runtime.mjs'; + +const help = `Usage: node .agents/skills/verify-synac/scripts/launch.mjs + +Starts the local SynAc stack in tmux sessions synac-verify-convex and +synac-verify-web, syncs repo content, and waits until doctor is READY. + +Env: + VERIFY_WEB_MODE=start|dev start is a production build (default) + VERIFY_SKIP_BUILD=1 reuse apps/web/.next when present + VERIFY_SKIP_BROWSER_INSTALL=1 skip Playwright Chromium install + VERIFY_SKIP_INSTALL=1 skip pnpm install +`; + +const webEnv = { + NEXT_PUBLIC_CONVEX_URL: convexUrl, + NEXT_PUBLIC_SITE_URL: baseUrl, + SYNAC_CONVEX_SERVICE_KEY: 'local-dev', + SYNAC_RATE_LIMIT_SALT: 'local-dev', + E2E_BASE_URL: baseUrl, +}; + +function fail(message) { + console.error(message); + process.exit(2); +} + +function exportPrefix() { + const pathEnv = process.env.PATH ?? ''; + const pairs = { PATH: pathEnv, ...webEnv }; + return Object.entries(pairs) + .map(([key, value]) => `export ${key}=${shQuote(value)};`) + .join(' '); +} + +function remember(session, pidKey, port) { + const run = readRun(); + if (!run.sessions.includes(session)) run.sessions.push(session); + const pids = listenerPids(port); + const first = pids[0]; + if (first) run.pids[pidKey] = first; + run.baseUrl = baseUrl; + run.webMode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; + if (!run.startedAt) run.startedAt = new Date().toISOString(); + writeRun(run); +} + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + if (nodeMajor() < 24) { + fail( + `node ${process.version} is below 24. Put nvm Node 24 first on PATH, then rerun.`, + ); + } + if (!baseUrl.startsWith('http://127.0.0.1:3000') && baseUrl !== 'http://localhost:3000') { + fail(`refusing base URL ${baseUrl}. This skill owns http://127.0.0.1:3000.`); + } + + ensureState(); + const doctor = await runDoctor(baseUrl); + if (doctor.status === 'READY') { + console.log('REUSED'); + console.log(`evidence ${evidenceDir}`); + return; + } + + const webUp = await portOpen(webPort); + const convexUp = await portOpen(convexPort); + if (webUp && !sessionAlive(sessionWeb)) { + fail( + `port ${webPort} is open (${doctor.portOwner}) and this skill did not start it. Leave it alone.`, + ); + } + if (convexUp && !sessionAlive(sessionConvex)) { + fail( + `port ${convexPort} is open and this skill did not start it. Leave it alone.`, + ); + } + + if (process.env.VERIFY_SKIP_INSTALL !== '1' && !existsSync(path.join(root, 'node_modules'))) { + const install = spawnSync('pnpm', ['install', '--frozen-lockfile'], { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (install.status !== 0) fail('pnpm install failed'); + } + + if (process.env.VERIFY_SKIP_BROWSER_INSTALL !== '1') { + const browsers = spawnSync( + 'pnpm', + ['--filter', '@synac/e2e', 'exec', 'playwright', 'install', 'chromium'], + { cwd: root, stdio: 'inherit', env: process.env }, + ); + if (browsers.status !== 0) fail('playwright install chromium failed'); + } + + if (!convexUp) { + const log = path.join(stateDir, 'synac-verify-convex.log'); + startSession( + sessionConvex, + `${exportPrefix()} export CONVEX_AGENT_MODE=anonymous; cd ${shQuote(root)}; npx convex dev > ${shQuote(log)} 2>&1`, + ); + await waitFor( + 'Convex functions ready', + async () => { + try { + const { readFileSync } = await import('node:fs'); + return readFileSync(log, 'utf8').includes('Convex functions ready'); + } catch { + return false; + } + }, + 180_000, + ); + if (!(await portOpen(convexPort))) fail(`convex log went ready but port ${convexPort} is closed`); + remember(sessionConvex, 'convex', convexPort); + if (await portOpen(convexSitePort)) remember(sessionConvex, 'convexSite', convexSitePort); + } + + let envSet = 1; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const result = spawnSync( + 'npx', + ['convex', 'env', 'set', 'SYNAC_CONVEX_SERVICE_KEY', 'local-dev'], + { cwd: root, stdio: 'inherit', env: { ...process.env, CONVEX_AGENT_MODE: 'anonymous' } }, + ); + envSet = result.status ?? 1; + if (envSet === 0) break; + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + if (envSet !== 0) fail('npx convex env set SYNAC_CONVEX_SERVICE_KEY failed'); + + const sync = runLogged( + 'pnpm', + ['--filter', '@synac/content-tools', 'sync'], + 'cli-sync.log', + { timeoutMs: 600_000, env: { CONVEX_AGENT_MODE: 'anonymous' } }, + ); + if (sync.status !== 0 || !sync.stdout.includes('tag convergence: verified')) { + fail(`content sync failed. See ${sync.logPath}`); + } + console.log(`sync log ${sync.logPath}`); + + const mode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; + const buildId = path.join(root, 'apps/web/.next/BUILD_ID'); + const skipBuild = mode === 'dev' || (process.env.VERIFY_SKIP_BUILD === '1' && existsSync(buildId)); + if (!skipBuild) { + const build = spawnSync('pnpm', ['--filter', '@synac/web', 'build'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, ...webEnv }, + }); + if (build.status !== 0) fail('web build failed'); + } + + if (!(await portOpen(webPort))) { + const log = path.join(stateDir, 'synac-verify-web.log'); + const webCommand = + mode === 'dev' + ? 'pnpm --filter @synac/web dev -- -H 127.0.0.1 -p 3000' + : 'pnpm --filter @synac/web start -- -H 127.0.0.1 -p 3000'; + startSession( + sessionWeb, + `${exportPrefix()} cd ${shQuote(root)}; ${webCommand} > ${shQuote(log)} 2>&1`, + ); + await waitFor('web port', () => portOpen(webPort), 120_000); + remember(sessionWeb, 'web', webPort); + } + + let ready = await runDoctor(baseUrl); + const doctorDeadline = Date.now() + 60_000; + while (ready.status !== 'READY' && Date.now() < doctorDeadline) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + ready = await runDoctor(baseUrl); + } + if (ready.status !== 'READY') { + console.error(JSON.stringify(ready, null, 2)); + fail(`doctor is ${ready.code} after launch. Logs are in ${stateDir}`); + } + console.log('STARTED'); + console.log(`evidence ${evidenceDir}`); +} + +await main(); diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs new file mode 100755 index 00000000..7e31261d --- /dev/null +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -0,0 +1,226 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + writeFileSync, +} from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + +export const root = path.resolve(scriptDir, '../../../..'); +export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? '/tmp/synac-verify'; +export const evidenceDir = + process.env.VERIFY_EVIDENCE_DIR ?? path.join(stateDir, 'evidence'); +export const runFile = path.join(stateDir, 'run.json'); +export const baseUrl = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3000'; +export const webPort = 3000; +export const convexPort = 3210; +export const convexSitePort = 3211; +export const convexUrl = 'http://127.0.0.1:3210'; +export const sessionConvex = 'synac-verify-convex'; +export const sessionWeb = 'synac-verify-web'; +export const ownedSessions = new Set([sessionConvex, sessionWeb]); + +const tmuxConfig = process.env.SYNAC_VERIFY_TMUX_CONFIG + ? process.env.SYNAC_VERIFY_TMUX_CONFIG + : existsSync('/exec-daemon/tmux.portal.conf') + ? '/exec-daemon/tmux.portal.conf' + : ''; + +export function ensureState() { + mkdirSync(stateDir, { recursive: true }); + mkdirSync(evidenceDir, { recursive: true }); +} + +export function readRun() { + try { + const value = JSON.parse(readFileSync(runFile, 'utf8')); + if (!value || typeof value !== 'object') return emptyRun(); + return { + sessions: Array.isArray(value.sessions) ? value.sessions : [], + pids: value.pids && typeof value.pids === 'object' ? value.pids : {}, + startedAt: typeof value.startedAt === 'string' ? value.startedAt : '', + webMode: value.webMode === 'dev' ? 'dev' : 'start', + baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : baseUrl, + }; + } catch { + return emptyRun(); + } +} + +function emptyRun() { + return { + sessions: [], + pids: {}, + startedAt: '', + webMode: 'start', + baseUrl, + }; +} + +export function writeRun(run) { + ensureState(); + writeFileSync(runFile, `${JSON.stringify(run, null, 2)}\n`); +} + +export function shQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +export function tmux(args, options = {}) { + const prefix = tmuxConfig ? ['-f', tmuxConfig] : []; + return spawnSync('tmux', [...prefix, ...args], { + encoding: 'utf8', + ...options, + }); +} + +export function sessionAlive(name) { + if (!ownedSessions.has(name)) return false; + const result = tmux(['has-session', '-t', name], { stdio: 'ignore' }); + return result.status === 0; +} + +export function startSession(name, script) { + if (!ownedSessions.has(name)) { + throw new Error(`refusing to start unknown session ${name}`); + } + const result = tmux( + ['new-session', '-d', '-s', name, '-c', root, '--', 'bash', '-c', script], + { stdio: 'inherit' }, + ); + if (result.status !== 0) { + throw new Error(`tmux new-session ${name} failed`); + } +} + +export function portOpen(port, host = '127.0.0.1') { + return new Promise((resolve) => { + const socket = net.connect({ host, port }); + const done = (open) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(open); + }; + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + socket.setTimeout(500, () => done(false)); + }); +} + +function parseProcNet(file, port) { + let text = ''; + try { + text = readFileSync(file, 'utf8'); + } catch { + return []; + } + const hex = port.toString(16).toUpperCase().padStart(4, '0'); + const inodes = []; + for (const line of text.split('\n').slice(1)) { + const cols = line.trim().split(/\s+/); + const local = cols[1]; + const inode = cols[9]; + if (!local || !inode) continue; + if (local.endsWith(`:${hex}`)) inodes.push(inode); + } + return inodes; +} + +function pidForInode(inode) { + let pids = []; + try { + pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name)); + } catch { + return null; + } + const needle = `socket:[${inode}]`; + for (const pid of pids) { + let fds = []; + try { + fds = readdirSync(`/proc/${pid}/fd`); + } catch { + continue; + } + for (const fd of fds) { + try { + if (readlinkSync(`/proc/${pid}/fd/${fd}`) === needle) return Number(pid); + } catch { + continue; + } + } + } + return null; +} + +export function portOwner(port) { + const inodes = [ + ...parseProcNet('/proc/net/tcp', port), + ...parseProcNet('/proc/net/tcp6', port), + ]; + const pids = []; + for (const inode of inodes) { + const pid = pidForInode(inode); + if (pid && !pids.includes(pid)) pids.push(pid); + } + if (pids.length === 0) return 'none'; + return pids + .map((pid) => { + let cmd = ''; + try { + cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); + } catch { + cmd = ''; + } + return cmd ? `${pid} ${cmd}` : String(pid); + }) + .join('; '); +} + +export function listenerPids(port) { + const inodes = [ + ...parseProcNet('/proc/net/tcp', port), + ...parseProcNet('/proc/net/tcp6', port), + ]; + const pids = []; + for (const inode of inodes) { + const pid = pidForInode(inode); + if (pid && !pids.includes(pid)) pids.push(pid); + } + return pids; +} + +export async function waitFor(label, predicate, timeoutMs, intervalMs = 1000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`timed out waiting for ${label}`); +} + +export function runLogged(command, args, logName, options = {}) { + ensureState(); + const logPath = path.join(evidenceDir, logName); + const result = spawnSync(command, args, { + cwd: root, + encoding: 'utf8', + env: { ...process.env, ...options.env }, + timeout: options.timeoutMs, + }); + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? ''; + const header = `$ ${command} ${args.join(' ')}\nexit ${result.status ?? 'null'}\n`; + writeFileSync(logPath, `${header}\n${stdout}\n${stderr}`); + return { status: result.status ?? 1, stdout, stderr, logPath, error: result.error }; +} + +export function nodeMajor() { + return Number(process.versions.node.split('.')[0] ?? 0); +} diff --git a/.agents/skills/verify-synac/scripts/smoke.mjs b/.agents/skills/verify-synac/scripts/smoke.mjs new file mode 100755 index 00000000..2b72736c --- /dev/null +++ b/.agents/skills/verify-synac/scripts/smoke.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runDoctor } from './doctor.mjs'; +import { baseUrl, evidenceDir, ensureState, root } from './runtime.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const skillDir = path.resolve(scriptDir, '..'); + +const help = `Usage: node .agents/skills/verify-synac/scripts/smoke.mjs [--drive] + +Runs doctor, then the CLI checks, then Playwright. +--drive walks the feature map. The default is the short smoke path. +`; + +function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(help); + return; + } + ensureState(); + const drive = process.argv.includes('--drive'); + const doctor = spawnSync(process.execPath, [path.join(scriptDir, 'doctor.mjs')], { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (doctor.status !== 0) { + console.error('doctor is not READY. Browser and CLI drives were skipped.'); + process.exit(doctor.status ?? 2); + } + + const cliArgs = [path.join(scriptDir, 'cli.mjs')]; + if (!drive) cliArgs.push('--smoke'); + const cli = spawnSync(process.execPath, cliArgs, { + cwd: root, + stdio: 'inherit', + env: process.env, + }); + if (cli.status !== 0) process.exit(cli.status ?? 1); + + const spec = path.join(scriptDir, drive ? 'drive.spec.ts' : 'smoke.spec.ts'); + const config = path.join(skillDir, 'playwright.config.mjs'); + const playwright = path.join(root, 'apps/e2e/node_modules/@playwright/test/cli.js'); + const result = spawnSync( + process.execPath, + [playwright, 'test', '--config', config, spec], + { + cwd: root, + stdio: 'inherit', + env: { + ...process.env, + E2E_BASE_URL: baseUrl, + VERIFY_EVIDENCE_DIR: evidenceDir, + }, + }, + ); + process.exit(result.status ?? 1); +} + +main(); diff --git a/.agents/skills/verify-synac/scripts/smoke.spec.ts b/.agents/skills/verify-synac/scripts/smoke.spec.ts new file mode 100644 index 00000000..75baf8b8 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/smoke.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +import { capture } from './evidence'; + +test('home search opens an entry', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1, name: 'SynAc' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Recently updated' })).toBeVisible(); + await capture(page, 'smoke-home.png'); + + const search = page.getByRole('textbox', { name: 'Search' }); + await search.fill('soc'); + await search.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=soc/); + await expect(page.getByRole('heading', { level: 1, name: 'Search' })).toBeVisible(); + const results = page.locator('a[href^="/term/"], a[href^="/acronym/"]'); + await expect(results.first()).toBeVisible(); + await capture(page, 'smoke-search.png'); + + const href = await results.first().getAttribute('href'); + expect(href).toBeTruthy(); + await results.first().click(); + await expect(page).toHaveURL(new RegExp(`${href}$`)); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await capture(page, 'smoke-entry.png'); +}); diff --git a/.cursor/skills/verify-synac b/.cursor/skills/verify-synac new file mode 120000 index 00000000..39e1d174 --- /dev/null +++ b/.cursor/skills/verify-synac @@ -0,0 +1 @@ +../../.agents/skills/verify-synac \ No newline at end of file diff --git a/.github/workflows/verify-smoke.yml b/.github/workflows/verify-smoke.yml new file mode 100644 index 00000000..6ea01f50 --- /dev/null +++ b/.github/workflows/verify-smoke.yml @@ -0,0 +1,68 @@ +# Optional smoke for the verification skill. +# This workflow is not part of the required CI or Quality checks. +# Branch protection should not list it. + +name: Verify smoke + +on: + workflow_dispatch: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify-smoke: + name: Doctor and smoke + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.34.4 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Install tmux + run: sudo apt-get update && sudo apt-get install -y tmux + + - name: Install Playwright chromium + run: pnpm --filter @synac/e2e exec playwright install --with-deps chromium + + - name: Launch + run: pnpm verify:launch + env: + VERIFY_SKIP_BROWSER_INSTALL: "1" + + - name: Doctor + run: pnpm verify:doctor + + - name: Smoke + run: pnpm verify:smoke + + - name: Cleanup + if: always() + run: pnpm verify:cleanup + + - name: Upload evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: verify-smoke-evidence + path: /tmp/synac-verify/evidence + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index a4cabecf..c9d284cc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ build/ # Editor and agent scratch .vscode/ .idea/ -.cursor/ +.cursor/* +!.cursor/skills/ .claude/ # Build and test output diff --git a/AGENTS.md b/AGENTS.md index 297b275f..c686ba44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,10 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro - Local backend: `CONVEX_AGENT_MODE=anonymous npx convex dev`, then `pnpm --filter @synac/content-tools sync` - Regenerate a bundle: `pnpm ingest -- --source rfc4949` +## Verification + +Drive the local stack with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. + ## Cloud Agent notes - Node must be `24` (see `.node-version`). If the base image ships an older `node` ahead of nvm on `PATH` (e.g. `/exec-daemon/node`), put the nvm Node 24 bin directory first: `export PATH="$NVM_DIR/versions/node/$(nvm version 24)/bin:$PATH"`. diff --git a/package.json b/package.json index cb242e46..040349bf 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,12 @@ "version:check": "node scripts/checkVersions.mjs", "convex:dev": "convex dev", "convex:validate": "convex dev --once", - "ingest": "corepack pnpm --filter @synac/ingest-tools ingest" + "ingest": "corepack pnpm --filter @synac/ingest-tools ingest", + "verify:launch": "node .agents/skills/verify-synac/scripts/launch.mjs", + "verify:doctor": "node .agents/skills/verify-synac/scripts/doctor.mjs", + "verify:smoke": "node .agents/skills/verify-synac/scripts/smoke.mjs", + "verify:drive": "node .agents/skills/verify-synac/scripts/smoke.mjs --drive", + "verify:cleanup": "node .agents/skills/verify-synac/scripts/cleanup.mjs" }, "devDependencies": { "@edge-runtime/vm": "^5.0.0", From 00077f453c29b47b99aebe9ab8765596f18c2f19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 02:30:14 +0000 Subject: [PATCH 02/23] fix: start the verify web server with next exec pnpm start forwarded -H as a project directory, so the port never opened. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- .agents/skills/verify-synac/scripts/launch.mjs | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 8904a3b1..cad84eb0 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -45,7 +45,7 @@ pnpm verify:launch 7. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. 8. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. 9. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. -10. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web start -- -H 127.0.0.1 -p 3000`. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web dev` on the same host and port instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. +10. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. `docs/contributing/local-dev.md` is the same stack, split across terminals. Use the helper so the sessions are recorded for cleanup. diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs index e73d57b0..6c5de7c3 100755 --- a/.agents/skills/verify-synac/scripts/launch.mjs +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -189,13 +189,23 @@ async function main() { const log = path.join(stateDir, 'synac-verify-web.log'); const webCommand = mode === 'dev' - ? 'pnpm --filter @synac/web dev -- -H 127.0.0.1 -p 3000' - : 'pnpm --filter @synac/web start -- -H 127.0.0.1 -p 3000'; + ? 'pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000' + : 'pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000'; startSession( sessionWeb, `${exportPrefix()} cd ${shQuote(root)}; ${webCommand} > ${shQuote(log)} 2>&1`, ); - await waitFor('web port', () => portOpen(webPort), 120_000); + await waitFor( + 'web port', + async () => { + if (await portOpen(webPort)) return true; + if (!sessionAlive(sessionWeb)) { + throw new Error(`web session exited before port ${webPort} opened. See ${log}`); + } + return false; + }, + 120_000, + ); remember(sessionWeb, 'web', webPort); } From 2480c711f9dded8d7dee68721e569ab5ba6c22a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 02:39:52 +0000 Subject: [PATCH 03/23] fix: make the SynAc verify drive match the live UI Resolve Playwright from the e2e package, use lowercase letter links, and read the citation record the entry actually renders. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 3 +++ .../references/features/acronyms-browse.md | 4 ++-- .../references/features/command-palette.md | 1 + .../verify-synac/references/features/entry.md | 2 +- .../references/features/terms-browse.md | 2 +- .agents/skills/verify-synac/scripts/cli.mjs | 2 +- .../skills/verify-synac/scripts/drive.spec.ts | 23 +++++++++++-------- .agents/skills/verify-synac/scripts/smoke.mjs | 1 + 8 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index cad84eb0..2e5b0b6d 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -129,12 +129,15 @@ One UI feature, after doctor is `READY`: ```bash E2E_BASE_URL=http://127.0.0.1:3000 \ VERIFY_EVIDENCE_DIR=/tmp/synac-verify/evidence \ +NODE_PATH="$PWD/apps/e2e/node_modules" \ node apps/e2e/node_modules/@playwright/test/cli.js test \ --config .agents/skills/verify-synac/playwright.config.mjs \ .agents/skills/verify-synac/scripts/drive.spec.ts \ -g "command palette" ``` +`NODE_PATH` is required. The specs import `@playwright/test`, which is installed for `@synac/e2e` and is not hoisted to the repo root. `pnpm verify:smoke` and `pnpm verify:drive` set it. + Run that from the repo root. The config file is `playwright.config.mjs` next to this skill. One CLI feature: diff --git a/.agents/skills/verify-synac/references/features/acronyms-browse.md b/.agents/skills/verify-synac/references/features/acronyms-browse.md index b72835b7..d9e3f9ce 100644 --- a/.agents/skills/verify-synac/references/features/acronyms-browse.md +++ b/.agents/skills/verify-synac/references/features/acronyms-browse.md @@ -15,10 +15,10 @@ Header link `Acronyms`, or `/acronyms`. The heading is `Acronyms`. `drive.spec.ts` test `acronyms browse opens BLACK`. -Open `/acronyms`, click `B` in `Acronym letters`, then open `/acronym/black`. The heading is `BLACK`. Screenshot `acronym-black.png`. +Open `/acronyms`, click `b` in `Acronym letters` (the letter text is lowercase), then open `/acronym/black`. The heading is `BLACK`. Screenshot `acronym-black.png`. ## Gotchas -- Letter `B` is sorted by title. `BLACK` is near the start of that letter. If a future corpus pushes it past page 1, follow pagination `Next` until the link is visible and say so. +- Letter `b` is sorted by title. `BLACK` is near the start of that letter. If a future corpus pushes it past page 1, follow pagination `Next` until the link is visible and say so. - Empty letters use the sentence `No published acronyms yet for `. - A hostile `letter` value falls back to A, the same way the term index does. diff --git a/.agents/skills/verify-synac/references/features/command-palette.md b/.agents/skills/verify-synac/references/features/command-palette.md index a6bef69b..e21bd9fd 100644 --- a/.agents/skills/verify-synac/references/features/command-palette.md +++ b/.agents/skills/verify-synac/references/features/command-palette.md @@ -20,6 +20,7 @@ Click `Search` in the header, or press Ctrl+K (Command+K on macOS). Type, then E Wait until the header button `Search` is visible so the listener is attached. Press `Control+KeyK` until the combobox `Search terms and acronyms` is visible. Fill `soc`. The first option is `Search for "soc"`. Enter lands on `/search` or an entry. Screenshot `palette.png`. ```bash +NODE_PATH="$PWD/apps/e2e/node_modules" \ node apps/e2e/node_modules/@playwright/test/cli.js test \ --config .agents/skills/verify-synac/playwright.config.mjs \ .agents/skills/verify-synac/scripts/drive.spec.ts \ diff --git a/.agents/skills/verify-synac/references/features/entry.md b/.agents/skills/verify-synac/references/features/entry.md index af94eba1..a0cd9785 100644 --- a/.agents/skills/verify-synac/references/features/entry.md +++ b/.agents/skills/verify-synac/references/features/entry.md @@ -18,7 +18,7 @@ Open a search result, a browse row, or a direct URL such as `/term/access-contro `drive.spec.ts` test `entry page copy link and citation record`. -Open `/term/access-control`. The heading is `access control`. Click the first `#sense-` link and expect that fragment in the URL. Click `Copy link to this sense` and expect the status `Link copied`. Read the clipboard. It contains `/term/access-control#`. Open `Cite`, follow `Citation record (JSON)`, and expect HTTP 200 whose body names `rfc4949`. Screenshot `entry-access-control.png`. +Open `/term/access-control`. The heading is `access control`. Click the first visible `#sense-` link (the mobile chip row is hidden at desktop width) and expect that fragment in the URL. Click `Copy link to this sense` and expect the status `Link copied`. Read the clipboard. It contains `/term/access-control#`. Open `Cite`, follow `Citation record (JSON)`, and expect HTTP 200. The body `slug` is `access-control` and `primaryCitation.sourceSlug` is set. The lead sense on this entry is whichever source the compiler ranked first (NICCS in the current corpus), so do not require `rfc4949` on that first record. Screenshot `entry-access-control.png`. The acronym path is the end of the acronyms browse test: heading `BLACK`. diff --git a/.agents/skills/verify-synac/references/features/terms-browse.md b/.agents/skills/verify-synac/references/features/terms-browse.md index 042cf192..3b1623e3 100644 --- a/.agents/skills/verify-synac/references/features/terms-browse.md +++ b/.agents/skills/verify-synac/references/features/terms-browse.md @@ -18,7 +18,7 @@ Header link `Terms`, or `/terms`. The heading is `Terms`. `drive.spec.ts` test `terms browse letter, sort, filter, and a rejected letter`. -Open `/terms?letter=d`. The `D` link is current and `/term/domain` is in the list. Choose `Recently updated` in `Sort` and expect `sort=updated` in the URL. Type `access` into `Filter entries` and expect `q=access`. Screenshot `terms-d.png`. +Open `/terms?letter=d`. The letter link `d` is current and at least one `/term/` row is visible. Letter text is lowercase. `domain` is not on page 1 of D. Choose `Recently updated` in `Sort` and expect `sort=updated` in the URL. Type `domain` into `Filter entries` and expect the link `/term/domain`. Screenshot `terms-d.png`. `/terms?letter=DROP%20TABLE` falls back to letter A, which is current. diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index e5eda459..b7c8541a 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { evidenceDir, ensureState, root, runLogged, stateDir } from './runtime.mjs'; diff --git a/.agents/skills/verify-synac/scripts/drive.spec.ts b/.agents/skills/verify-synac/scripts/drive.spec.ts index 47030e33..03f3ddf8 100644 --- a/.agents/skills/verify-synac/scripts/drive.spec.ts +++ b/.agents/skills/verify-synac/scripts/drive.spec.ts @@ -54,22 +54,23 @@ test('terms browse letter, sort, filter, and a rejected letter', async ({ page } await page.goto('/terms?letter=d'); await expect(page.getByRole('heading', { level: 1, name: 'Terms' })).toBeVisible(); const letters = page.getByRole('navigation', { name: 'Term letters' }); - await expect(letters.getByRole('link', { name: 'D', exact: true })).toHaveAttribute( + await expect(letters.getByRole('link', { name: 'd', exact: true })).toHaveAttribute( 'aria-current', 'true', ); - await expect(page.locator('a[href="/term/domain"]')).toBeVisible(); + await expect(page.locator('a[href^="/term/"]').first()).toBeVisible(); await shot(page, 'terms-d.png'); await page.getByRole('combobox', { name: 'Sort' }).selectOption('updated'); await expect(page).toHaveURL(/sort=updated/); const filter = page.getByRole('textbox', { name: 'Filter entries' }); - await filter.fill('access'); - await expect(page).toHaveURL(/q=access/, { timeout: 10_000 }); + await filter.fill('domain'); + await expect(page).toHaveURL(/q=domain/, { timeout: 10_000 }); + await expect(page.locator('a[href="/term/domain"]')).toBeVisible(); await page.goto('/terms?letter=DROP%20TABLE'); - await expect(letters.getByRole('link', { name: 'A', exact: true })).toHaveAttribute( + await expect(letters.getByRole('link', { name: 'a', exact: true })).toHaveAttribute( 'aria-current', 'true', ); @@ -78,7 +79,7 @@ test('terms browse letter, sort, filter, and a rejected letter', async ({ page } test('acronyms browse opens BLACK', async ({ page }) => { await page.goto('/acronyms'); await expect(page.getByRole('heading', { level: 1, name: 'Acronyms' })).toBeVisible(); - await page.getByRole('navigation', { name: 'Acronym letters' }).getByRole('link', { name: 'B', exact: true }).click(); + await page.getByRole('navigation', { name: 'Acronym letters' }).getByRole('link', { name: 'b', exact: true }).click(); await expect(page).toHaveURL(/letter=b/); await page.locator('a[href="/acronym/black"]').click(); await expect(page.getByRole('heading', { level: 1, name: 'BLACK' })).toBeVisible(); @@ -89,7 +90,7 @@ test('entry page copy link and citation record', async ({ page, context, request await context.grantPermissions(['clipboard-read', 'clipboard-write']); await page.goto('/term/access-control'); await expect(page.getByRole('heading', { level: 1, name: 'access control' })).toBeVisible(); - const toc = page.locator('a[href^="#sense-"]').first(); + const toc = page.locator('a[href^="#sense-"]:visible').first(); await expect(toc).toBeVisible(); const fragment = await toc.getAttribute('href'); expect(fragment).toBeTruthy(); @@ -109,8 +110,12 @@ test('entry page copy link and citation record', async ({ page, context, request expect(href).toBeTruthy(); const response = await request.get(href ?? ''); expect(response.status()).toBe(200); - const body = (await response.json()) as { sourceSlug?: string }; - expect(JSON.stringify(body)).toContain('rfc4949'); + const body = (await response.json()) as { + slug?: string; + primaryCitation?: { sourceSlug?: string }; + }; + expect(body.slug).toBe('access-control'); + expect(body.primaryCitation?.sourceSlug).toBeTruthy(); }); test('tags filter, empty filter, and a tag page', async ({ page }) => { diff --git a/.agents/skills/verify-synac/scripts/smoke.mjs b/.agents/skills/verify-synac/scripts/smoke.mjs index 2b72736c..11f22d98 100755 --- a/.agents/skills/verify-synac/scripts/smoke.mjs +++ b/.agents/skills/verify-synac/scripts/smoke.mjs @@ -54,6 +54,7 @@ function main() { ...process.env, E2E_BASE_URL: baseUrl, VERIFY_EVIDENCE_DIR: evidenceDir, + NODE_PATH: path.join(root, 'apps/e2e/node_modules'), }, }, ); From 3c042028d9dde8d30d2e228e6a7fd34ed2a2512d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:17:43 +0000 Subject: [PATCH 04/23] fix: keep a SynAc verify run from leaking processes or a dirty tree Record tmux sessions as soon as they start, signal a saved pid only when its start time still matches, create the state dir mode 0700, and restore Convex and Next files a launch rewrites. A failed ingest fails the drive. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 17 +- .../references/features/README.md | 2 +- .../references/features/ingest.md | 4 +- .../skills/verify-synac/scripts/cleanup.mjs | 39 ++- .agents/skills/verify-synac/scripts/cli.mjs | 15 +- .../skills/verify-synac/scripts/launch.mjs | 71 +++-- .../skills/verify-synac/scripts/runtime.mjs | 248 +++++++++++++++++- AGENTS.md | 4 +- apps/web/AGENTS.md | 2 +- 9 files changed, 346 insertions(+), 56 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 2e5b0b6d..ff2891b7 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -25,6 +25,8 @@ Playwright uses a fresh browser context per test, so theme and clipboard stay in ## Launch +`pnpm verify:*` is defined on the repo root. Run it from the root of this checkout. From a package directory, pnpm looks at that package's scripts and exits 254, so use `pnpm -w verify:launch` (and the same `-w` form for doctor, smoke, drive, and cleanup). Each script finds this repo from its own file path and changes to that directory before it runs a content command or Playwright. + From the repo root, with Node 24 first on `PATH` (see `.node-version`): ```bash @@ -41,23 +43,26 @@ pnpm verify:launch 3. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 4. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. 5. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. -6. Start tmux session `synac-verify-convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Ready means the log line `Convex functions ready` in `/tmp/synac-verify/synac-verify-convex.log`. +6. Start tmux session `synac-verify-convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Stdin is `/dev/null`, so Convex does not run its interactive agent-file install. The session name is written to `run.json` as soon as tmux returns, before the ready check. A launch that times out still leaves the session for cleanup. Ready means the log line `Convex functions ready` in `/tmp/synac-verify/synac-verify-convex.log`. The listener pid is stored with its `/proc` start time once port 3210 is open. 7. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. 8. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. 9. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. -10. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. +10. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The session is written to `run.json` before the port is open. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. `docs/contributing/local-dev.md` is the same stack, split across terminals. Use the helper so the sessions are recorded for cleanup. Logs and state: -- `/tmp/synac-verify/run.json` lists sessions and listener pids this launch started +- `/tmp/synac-verify` is mode `0700` and owned by the user who ran launch. Launch exits if the mode or the owner is wrong. +- `/tmp/synac-verify/run.json` lists sessions and listener pids this launch started. Each pid keeps the process start time and command line from `/proc`. - `/tmp/synac-verify/synac-verify-convex.log` - `/tmp/synac-verify/synac-verify-web.log` - `/tmp/synac-verify/evidence/` is created at launch and kept by cleanup `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. +`npx convex dev` can rewrite `.agents/skills/convex*`, `convex/_generated/ai/`, and `skills-lock.json`, and it can add new skill directories. `next dev` can rewrite `AGENTS.md`, `apps/web/AGENTS.md`, and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. Launch and cleanup then restore any of those paths that were clean when launch started. Do not commit that rewrite. After cleanup, `git status` is clean aside from changes you already had. + ## Doctor Run this first whenever a drive looks wrong. It only sends GET requests. @@ -194,7 +199,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. If the fetch cannot finish, the helper records `INCONCLUSIVE` in `cli-report.json` and still requires the local rejection checks (no args, unknown source) to pass. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. A non-zero ingest exit fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. That result is a failure, including when the upstream fetch does not finish. ## Cleanup @@ -202,7 +207,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them, and it signals the listener pids recorded in that file. It does not kill processes by name. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. It does not kill processes by name. It restores Convex and Next generated files that launch snapshotted as clean, leaves `/tmp/synac-verify/evidence/` in place, and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. @@ -235,7 +240,7 @@ Put a Proof section on the pull request: - Commands: the exact commands, including `pnpm verify:doctor` and the drive you ran - Evidence: command logs, screenshot paths, and trace paths that still exist after cleanup - Results: pass or fail per command, plus the observable end state (URL, heading, file written, record read back, attribute) -- INCONCLUSIVE: any step that could not run, and the concrete reason (port busy, upstream fetch failed, revalidate secret unset, browser library missing) +- INCONCLUSIVE: any step that could not run, and the concrete reason (port busy, revalidate secret unset, browser library missing). A failed ingest is a failure, not `INCONCLUSIVE`. ``` A missing check is `INCONCLUSIVE`. Do not describe it as passed. diff --git a/.agents/skills/verify-synac/references/features/README.md b/.agents/skills/verify-synac/references/features/README.md index 66f20784..53ea6823 100644 --- a/.agents/skills/verify-synac/references/features/README.md +++ b/.agents/skills/verify-synac/references/features/README.md @@ -18,7 +18,7 @@ The relevant feature file is the coverage set. When a change crosses search, an - Exercise every reachable entry point the change can affect, including empty, error, and hostile inputs listed in Gotchas. - Record the action and the final observable state, including files written and records read back. - Mocks count only where the product already isolates an upstream fetch. The glossary reads the synced Convex dataset. Do not stub those reads. -- When a path is unreachable, name it and the concrete prerequisite. Live RFC ingest is `INCONCLUSIVE` when the upstream fetch cannot finish. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. +- When a path is unreachable, name it and the concrete prerequisite. A live RFC ingest that does not finish fails the drive. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. ## Full sweep diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index a611e589..cc5815da 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,11 +45,11 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the upstream fetch cannot finish, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The usage and missing-source checks still have to pass. +If the upstream fetch cannot finish, `pnpm verify:drive` exits 1. Keep the log `cli-ingest-rfc4949.log`. Do not record that result as `INCONCLUSIVE`. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas - `--all` walks every source and will try the network for each. The sweep uses `rfc4949` only. - A run with `SYNAC_CONTENT_DIR` unset writes `content/generated/` in the repo. The helper always sets the variable for the live fetch. - `unchanged` means the new bundle compared equal to the previous file. Confirm the isolated file still exists. The word alone is not the side effect. -- Adapters that need a secret or a blocked host are `INCONCLUSIVE` with the error text. Do not vendor a fake RFC into product content to force a pass. +- Adapters that need a secret or a blocked host fail the drive. The log has the error text. Do not vendor a fake RFC into product content to force a pass. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index 224a78aa..92e6e9d0 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -4,7 +4,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { evidenceDir, ownedSessions, + processIdentity, readRun, + restoreGeneratedDrift, + sameProcess, sessionAlive, tmux, writeRun, @@ -13,7 +16,8 @@ import { const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) -and the listener pids recorded in /tmp/synac-verify/run.json. +and the listener pids recorded in /tmp/synac-verify/run.json, after checking +each pid's start time. Restores Convex and Next files this launch rewrote. Leaves /tmp/synac-verify/evidence/ in place. `; @@ -55,16 +59,35 @@ function main() { } } - const recorded = [run.pids.convex, run.pids.web, run.pids.convexSite].filter( - (pid) => Number.isInteger(pid), - ); - for (const pid of recorded) killTree(pid); - if (recorded.length > 0) console.log(`signaled pids ${recorded.join(', ')}`); + const recorded = [ + ['convex', run.pids.convex], + ['web', run.pids.web], + ['convexSite', run.pids.convexSite], + ]; + const signaled = []; + for (const [name, saved] of recorded) { + if (!saved) continue; + const live = processIdentity(saved.pid); + if (!live) { + console.log(`pid ${saved.pid} (${name}) already exited`); + continue; + } + if (!sameProcess(saved, saved.pid)) { + console.log(`skipped pid ${saved.pid} (${name}): it is no longer the process launch started`); + continue; + } + killTree(saved.pid); + signaled.push(saved.pid); + } + if (signaled.length > 0) console.log(`signaled pids ${signaled.join(', ')}`); + restoreGeneratedDrift(); + const cleared = readRun(); writeRun({ - ...run, + ...cleared, sessions: [], - pids: {}, + pids: { convex: null, web: null, convexSite: null }, + dirtyBefore: undefined, }); console.log(`evidence ${evidenceDir}`); } diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index b7c8541a..049cde6c 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -11,8 +11,7 @@ Runs the content CLI and checks side effects. --smoke content:check and content:compile (writes tools/content/dist/compiled.json) default smoke, plus content:history, a hostile content dir, ingest usage, - and an isolated rfc4949 ingest. Live upstream fetch that cannot run - is recorded as INCONCLUSIVE and does not fail the local checks. + and an isolated rfc4949 ingest. A failed ingest exits 1. `; const compiledPath = path.join(root, 'tools/content/dist/compiled.json'); @@ -144,14 +143,10 @@ function ingestStep(report) { }); return; } - report.steps.push({ - name: 'ingest-rfc4949', - status: 'INCONCLUSIVE', - reason: - 'live upstream fetch did not finish in this environment. Local ingest rejection checks already passed. See cli-ingest-rfc4949.log', - log: result.logPath, - }); - console.log('INCONCLUSIVE ingest rfc4949: upstream fetch did not finish'); + const detail = text.trim().split('\n').slice(-8).join(' '); + throw new Error( + `ingest rfc4949 failed (exit ${result.status}). ${detail} See ${result.logPath}`, + ); } function main() { diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs index 6c5de7c3..15c6ecbe 100755 --- a/.agents/skills/verify-synac/scripts/launch.mjs +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { runDoctor } from './doctor.mjs'; @@ -11,10 +11,13 @@ import { convexUrl, ensureState, evidenceDir, - listenerPids, nodeMajor, + noteSession, portOpen, readRun, + rememberListener, + rememberWorktree, + restoreGeneratedDrift, root, runLogged, sessionAlive, @@ -25,7 +28,6 @@ import { stateDir, waitFor, webPort, - writeRun, } from './runtime.mjs'; const help = `Usage: node .agents/skills/verify-synac/scripts/launch.mjs @@ -49,8 +51,9 @@ const webEnv = { }; function fail(message) { - console.error(message); - process.exit(2); + const error = new Error(message); + error.exitCode = 2; + throw error; } function exportPrefix() { @@ -61,23 +64,29 @@ function exportPrefix() { .join(' '); } -function remember(session, pidKey, port) { - const run = readRun(); - if (!run.sessions.includes(session)) run.sessions.push(session); - const pids = listenerPids(port); - const first = pids[0]; - if (first) run.pids[pidKey] = first; - run.baseUrl = baseUrl; - run.webMode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; - if (!run.startedAt) run.startedAt = new Date().toISOString(); - writeRun(run); -} - async function main() { if (process.argv.includes('--help') || process.argv.includes('-h')) { process.stdout.write(help); return; } + try { + await run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + const exitCode = error instanceof Error && 'exitCode' in error && error.exitCode === 2 ? 2 : 1; + process.exitCode = exitCode; + } finally { + try { + restoreGeneratedDrift(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + if (!process.exitCode) process.exitCode = 1; + } + } +} + +async function run() { if (nodeMajor() < 24) { fail( `node ${process.version} is below 24. Put nvm Node 24 first on PATH, then rerun.`, @@ -88,12 +97,14 @@ async function main() { } ensureState(); + if (Array.isArray(readRun().dirtyBefore)) restoreGeneratedDrift(); const doctor = await runDoctor(baseUrl); if (doctor.status === 'READY') { console.log('REUSED'); console.log(`evidence ${evidenceDir}`); return; } + rememberWorktree(); const webUp = await portOpen(webPort); const convexUp = await portOpen(convexPort); @@ -102,11 +113,20 @@ async function main() { `port ${webPort} is open (${doctor.portOwner}) and this skill did not start it. Leave it alone.`, ); } + if (webUp) { + noteSession(sessionWeb); + rememberListener('web', webPort); + } if (convexUp && !sessionAlive(sessionConvex)) { fail( `port ${convexPort} is open and this skill did not start it. Leave it alone.`, ); } + if (convexUp) { + noteSession(sessionConvex); + rememberListener('convex', convexPort); + if (await portOpen(convexSitePort)) rememberListener('convexSite', convexSitePort); + } if (process.env.VERIFY_SKIP_INSTALL !== '1' && !existsSync(path.join(root, 'node_modules'))) { const install = spawnSync('pnpm', ['install', '--frozen-lockfile'], { @@ -130,13 +150,14 @@ async function main() { const log = path.join(stateDir, 'synac-verify-convex.log'); startSession( sessionConvex, - `${exportPrefix()} export CONVEX_AGENT_MODE=anonymous; cd ${shQuote(root)}; npx convex dev > ${shQuote(log)} 2>&1`, + `${exportPrefix()} export CONVEX_AGENT_MODE=anonymous; cd ${shQuote(root)}; npx convex dev < /dev/null > ${shQuote(log)} 2>&1`, ); + noteSession(sessionConvex); await waitFor( 'Convex functions ready', async () => { + if (await portOpen(convexPort)) rememberListener('convex', convexPort); try { - const { readFileSync } = await import('node:fs'); return readFileSync(log, 'utf8').includes('Convex functions ready'); } catch { return false; @@ -145,8 +166,8 @@ async function main() { 180_000, ); if (!(await portOpen(convexPort))) fail(`convex log went ready but port ${convexPort} is closed`); - remember(sessionConvex, 'convex', convexPort); - if (await portOpen(convexSitePort)) remember(sessionConvex, 'convexSite', convexSitePort); + rememberListener('convex', convexPort); + if (await portOpen(convexSitePort)) rememberListener('convexSite', convexSitePort); } let envSet = 1; @@ -195,10 +216,14 @@ async function main() { sessionWeb, `${exportPrefix()} cd ${shQuote(root)}; ${webCommand} > ${shQuote(log)} 2>&1`, ); + noteSession(sessionWeb); await waitFor( 'web port', async () => { - if (await portOpen(webPort)) return true; + if (await portOpen(webPort)) { + rememberListener('web', webPort); + return true; + } if (!sessionAlive(sessionWeb)) { throw new Error(`web session exited before port ${webPort} opened. See ${log}`); } @@ -206,7 +231,7 @@ async function main() { }, 120_000, ); - remember(sessionWeb, 'web', webPort); + rememberListener('web', webPort); } let ready = await runDoctor(baseUrl); diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 7e31261d..fb9e16ae 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -1,10 +1,16 @@ import { spawnSync } from 'node:child_process'; import { + chmodSync, + copyFileSync, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, + rmdirSync, + rmSync, + statSync, writeFileSync, } from 'node:fs'; import net from 'node:net'; @@ -14,6 +20,20 @@ import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); export const root = path.resolve(scriptDir, '../../../..'); +if (process.cwd() !== root) process.chdir(root); + +const nextEnvFile = path.join(root, 'apps/web/next-env.d.ts'); +const nextEnvMeta = 'next-env.meta.json'; +const nextEnvSnapshot = 'next-env.snapshot'; + +const generatedFiles = new Set([ + 'skills-lock.json', + 'AGENTS.md', + 'CLAUDE.md', + 'apps/web/AGENTS.md', + 'apps/web/next-env.d.ts', + 'convex.json', +]); export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? '/tmp/synac-verify'; export const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? path.join(stateDir, 'evidence'); @@ -34,20 +54,41 @@ const tmuxConfig = process.env.SYNAC_VERIFY_TMUX_CONFIG : ''; export function ensureState() { - mkdirSync(stateDir, { recursive: true }); - mkdirSync(evidenceDir, { recursive: true }); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const listed = lstatSync(stateDir); + if (listed.isSymbolicLink() || !listed.isDirectory()) { + throw new Error(`${stateDir} is not a private directory`); + } + const uid = typeof process.getuid === 'function' ? process.getuid() : null; + if (uid !== null && listed.uid !== uid) { + throw new Error(`${stateDir} is owned by uid ${listed.uid}, expected ${uid}`); + } + chmodSync(stateDir, 0o700); + const mode = statSync(stateDir).mode & 0o777; + if (mode !== 0o700) { + throw new Error(`${stateDir} mode is 0${mode.toString(8)}, expected 0700`); + } + mkdirSync(evidenceDir, { recursive: true, mode: 0o700 }); } export function readRun() { try { const value = JSON.parse(readFileSync(runFile, 'utf8')); if (!value || typeof value !== 'object') return emptyRun(); + const pids = value.pids && typeof value.pids === 'object' ? value.pids : {}; return { sessions: Array.isArray(value.sessions) ? value.sessions : [], - pids: value.pids && typeof value.pids === 'object' ? value.pids : {}, + pids: { + convex: normalizePid(pids.convex), + web: normalizePid(pids.web), + convexSite: normalizePid(pids.convexSite), + }, startedAt: typeof value.startedAt === 'string' ? value.startedAt : '', webMode: value.webMode === 'dev' ? 'dev' : 'start', baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : baseUrl, + dirtyBefore: Array.isArray(value.dirtyBefore) + ? value.dirtyBefore.filter((item) => typeof item === 'string') + : undefined, }; } catch { return emptyRun(); @@ -57,13 +98,23 @@ export function readRun() { function emptyRun() { return { sessions: [], - pids: {}, + pids: { convex: null, web: null, convexSite: null }, startedAt: '', webMode: 'start', baseUrl, }; } +function normalizePid(value) { + if (!value || typeof value !== 'object') return null; + const pid = value.pid; + if (!Number.isInteger(pid) || pid <= 1) return null; + const startTime = typeof value.startTime === 'string' ? value.startTime : ''; + const cmdline = typeof value.cmdline === 'string' ? value.cmdline : ''; + if (!startTime && !cmdline) return null; + return { pid, startTime, cmdline }; +} + export function writeRun(run) { ensureState(); writeFileSync(runFile, `${JSON.stringify(run, null, 2)}\n`); @@ -224,3 +275,192 @@ export function runLogged(command, args, logName, options = {}) { export function nodeMajor() { return Number(process.versions.node.split('.')[0] ?? 0); } + +export function noteSession(session) { + if (!ownedSessions.has(session)) { + throw new Error(`refusing to record unknown session ${session}`); + } + const run = readRun(); + if (!run.sessions.includes(session)) run.sessions.push(session); + run.baseUrl = baseUrl; + run.webMode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; + if (!run.startedAt) run.startedAt = new Date().toISOString(); + writeRun(run); +} + +export function processIdentity(pid) { + if (!Number.isInteger(pid) || pid <= 1) return null; + let cmdline = ''; + try { + cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' ').trim(); + } catch { + return null; + } + let startTime = ''; + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const end = stat.lastIndexOf(')'); + if (end !== -1) { + const fields = stat.slice(end + 2).trim().split(/\s+/); + startTime = fields[19] ?? ''; + } + } catch { + startTime = ''; + } + if (!cmdline && !startTime) return null; + return { pid, startTime, cmdline }; +} + +export function sameProcess(saved, pid) { + if (!saved || saved.pid !== pid) return false; + const live = processIdentity(pid); + if (!live) return false; + if (saved.startTime && live.startTime) return saved.startTime === live.startTime; + if (saved.cmdline && live.cmdline) return saved.cmdline === live.cmdline; + return false; +} + +export function rememberListener(pidKey, port) { + const pid = listenerPids(port)[0]; + if (!pid) return; + const identity = processIdentity(pid); + if (!identity || (!identity.startTime && !identity.cmdline)) return; + const run = readRun(); + run.pids[pidKey] = identity; + writeRun(run); +} + +function generatedDrift(rel) { + if (generatedFiles.has(rel)) return true; + if (rel.startsWith('.agents/skills/convex')) return true; + if (rel === 'convex/_generated/ai' || rel.startsWith('convex/_generated/ai/')) return true; + if (rel === '.cursor/rules/convex_rules.mdc') return true; + return false; +} + +function worktreeEntries() { + const result = spawnSync('git', ['status', '--porcelain=v1', '-uall'], { + cwd: root, + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(`git status failed: ${result.stderr ?? ''}`.trim()); + } + const entries = []; + for (const line of (result.stdout ?? '').split('\n')) { + if (!line) continue; + const code = line.slice(0, 2); + let rel = line.slice(3); + if (rel.startsWith('"') && rel.endsWith('"')) rel = rel.slice(1, -1); + if (rel.endsWith('/')) rel = rel.slice(0, -1); + if (rel.includes(' -> ')) { + const renamed = rel.split(' -> ').at(-1); + if (renamed) rel = renamed; + } + entries.push({ code, rel }); + } + return entries; +} + +function snapshotNextEnv() { + ensureState(); + const metaPath = path.join(stateDir, nextEnvMeta); + const snapPath = path.join(stateDir, nextEnvSnapshot); + if (!existsSync(nextEnvFile)) { + writeFileSync(metaPath, `${JSON.stringify({ existed: false })}\n`); + rmSync(snapPath, { force: true }); + return; + } + copyFileSync(nextEnvFile, snapPath); + writeFileSync(metaPath, `${JSON.stringify({ existed: true })}\n`); +} + +function restoreNextEnv() { + const metaPath = path.join(stateDir, nextEnvMeta); + if (!existsSync(metaPath)) return; + const meta = JSON.parse(readFileSync(metaPath, 'utf8')); + const snapPath = path.join(stateDir, nextEnvSnapshot); + if (meta.existed !== true) { + if (existsSync(nextEnvFile)) rmSync(nextEnvFile, { force: true }); + return; + } + if (!existsSync(snapPath)) return; + copyFileSync(snapPath, nextEnvFile); +} + +export function rememberWorktree() { + const run = readRun(); + run.dirtyBefore = worktreeEntries().map((entry) => entry.rel); + writeRun(run); + snapshotNextEnv(); +} + +function removeUntracked(rel) { + const cleaned = rel.replace(/\/+$/, ''); + const abs = path.join(root, cleaned); + let listed; + try { + listed = lstatSync(abs); + } catch { + return; + } + if (listed.isSymbolicLink()) { + rmSync(abs, { force: true }); + } else if (listed.isDirectory()) { + const tracked = spawnSync('git', ['ls-files', '--', cleaned], { + cwd: root, + encoding: 'utf8', + }); + if ((tracked.stdout ?? '').trim().length > 0) return; + rmSync(abs, { recursive: true, force: true }); + return; + } else { + rmSync(abs, { force: true }); + } + let parent = path.dirname(cleaned); + while (parent && parent !== '.' && generatedDrift(parent)) { + const tracked = spawnSync('git', ['ls-files', '--', parent], { + cwd: root, + encoding: 'utf8', + }); + if ((tracked.stdout ?? '').trim().length > 0) break; + const absParent = path.join(root, parent); + let names = []; + try { + names = readdirSync(absParent); + } catch { + break; + } + if (names.length > 0) break; + rmdirSync(absParent); + parent = path.dirname(parent); + } +} + +export function restoreGeneratedDrift() { + const run = readRun(); + if (!Array.isArray(run.dirtyBefore)) return []; + const before = new Set(run.dirtyBefore); + const restore = []; + const remove = []; + for (const entry of worktreeEntries()) { + if (before.has(entry.rel) || !generatedDrift(entry.rel)) continue; + if (entry.code === '??') remove.push(entry.rel); + else restore.push(entry.rel); + } + if (restore.length > 0) { + const result = spawnSync( + 'git', + ['restore', '--source=HEAD', '--worktree', '--staged', '--', ...restore], + { cwd: root, encoding: 'utf8' }, + ); + if (result.status !== 0) { + throw new Error(`git restore failed: ${result.stderr ?? ''}`.trim()); + } + } + for (const rel of [...remove].sort((a, b) => b.length - a.length)) removeUntracked(rel); + restoreNextEnv(); + const touched = [...restore, ...remove]; + if (touched.length > 0) console.log(`restored generated files: ${touched.join(', ')}`); + return touched; +} diff --git a/AGENTS.md b/AGENTS.md index c686ba44..d72bdbc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,9 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro ## Verification -Drive the local stack with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. +Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. + +`npx convex dev` can rewrite Convex skill files, `skills-lock.json`, and `convex/_generated/ai/`. `next dev` can rewrite `AGENTS.md`, `apps/web/AGENTS.md`, and `apps/web/next-env.d.ts`. `pnpm verify:launch` and `pnpm verify:cleanup` put those paths back. Do not commit that rewrite. ## Cloud Agent notes diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 643577df..bbef21f3 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -4,6 +4,6 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. +This block is written and re-added by `next dev`. Verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. A verify run restores this file when `next dev` rewrites it. Do not commit that rewrite. From ba037ffc05634b286519945a03af2ab069cd98b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:17:44 +0000 Subject: [PATCH 05/23] fix: run the verify smoke workflow only on dispatch Drop the pull_request trigger, keep the evidence artifact upload, and checkout without persisting credentials. Co-authored-by: Aman Thanvi --- .github/workflows/verify-smoke.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-smoke.yml b/.github/workflows/verify-smoke.yml index 6ea01f50..51ce6c14 100644 --- a/.github/workflows/verify-smoke.yml +++ b/.github/workflows/verify-smoke.yml @@ -6,7 +6,6 @@ name: Verify smoke on: workflow_dispatch: - pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -22,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 From 6ef85b2702d252688524b394cbf88382cfbfcd05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:25:10 +0000 Subject: [PATCH 06/23] fix: signal each recorded verify pid once Convex can listen on the cloud port and the site port with one process. Cleanup checks that pid a single time. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/scripts/cleanup.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index 92e6e9d0..f703f90f 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -65,8 +65,11 @@ function main() { ['convexSite', run.pids.convexSite], ]; const signaled = []; + const seen = new Set(); for (const [name, saved] of recorded) { if (!saved) continue; + if (seen.has(saved.pid)) continue; + seen.add(saved.pid); const live = processIdentity(saved.pid); if (!live) { console.log(`pid ${saved.pid} (${name}) already exited`); From 2c77ad7e63ce3bb18908f32c579ac863e127a5d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:25:22 +0000 Subject: [PATCH 07/23] docs: note that verify cleanup signals a shared pid once Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index ff2891b7..ee982b85 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -207,7 +207,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. It does not kill processes by name. It restores Convex and Next generated files that launch snapshotted as clean, leaves `/tmp/synac-verify/evidence/` in place, and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It restores Convex and Next generated files that launch snapshotted as clean, leaves `/tmp/synac-verify/evidence/` in place, and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. From 8783c43e42d73682365dd1793328471e550c8ac6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:33:11 +0000 Subject: [PATCH 08/23] fix: stop verify restore from reverting hand-edited files Back up regenerated Convex and Next files before restoring the worktree, leave the index alone, and warn instead of reverting AGENTS.md, CLAUDE.md, and convex.json. Refuse an existing state directory whose mode is not 0700. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 10 +- .../skills/verify-synac/scripts/cleanup.mjs | 3 +- .../skills/verify-synac/scripts/runtime.mjs | 96 ++++++++++++++----- AGENTS.md | 2 +- apps/web/AGENTS.md | 4 +- 5 files changed, 82 insertions(+), 33 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index ee982b85..e50a335a 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -53,7 +53,7 @@ pnpm verify:launch Logs and state: -- `/tmp/synac-verify` is mode `0700` and owned by the user who ran launch. Launch exits if the mode or the owner is wrong. +- `/tmp/synac-verify` is created mode `0700` and must be owned by the user who ran launch. If that directory already exists with another mode or owner, launch exits and does not change its permissions. - `/tmp/synac-verify/run.json` lists sessions and listener pids this launch started. Each pid keeps the process start time and command line from `/proc`. - `/tmp/synac-verify/synac-verify-convex.log` - `/tmp/synac-verify/synac-verify-web.log` @@ -61,7 +61,7 @@ Logs and state: `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -`npx convex dev` can rewrite `.agents/skills/convex*`, `convex/_generated/ai/`, and `skills-lock.json`, and it can add new skill directories. `next dev` can rewrite `AGENTS.md`, `apps/web/AGENTS.md`, and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. Launch and cleanup then restore any of those paths that were clean when launch started. Do not commit that rewrite. After cleanup, `git status` is clean aside from changes you already had. +`npx convex dev` can rewrite `.agents/skills/convex*`, `convex/_generated/ai/`, and `skills-lock.json`, and it can add new skill directories. `next dev` can rewrite `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. Launch and cleanup copy those regenerated paths to `/tmp/synac-verify/restored/` and list each backup, then put the worktree copy back. They do not restore the git index. `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, and `convex.json` are hand-maintained. A change to one of those during the run is left in place and printed as a warning. ## Doctor @@ -199,7 +199,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. A non-zero ingest exit fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. That result is a failure, including when the upstream fetch does not finish. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. When the network is unreachable (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed` with no HTTP status), the step is `INCONCLUSIVE` and the drive continues. Any other non-zero ingest, including `fetch failed (404)` or a parser error, fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup @@ -207,7 +207,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It restores Convex and Next generated files that launch snapshotted as clean, leaves `/tmp/synac-verify/evidence/` in place, and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It backs up regenerated Convex skill files, `skills-lock.json`, `convex/_generated/ai/`, and `apps/web/next-env.d.ts` under `/tmp/synac-verify/restored/`, lists those backups, and restores only the worktree. It warns and leaves `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, and `convex.json` in place. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. @@ -240,7 +240,7 @@ Put a Proof section on the pull request: - Commands: the exact commands, including `pnpm verify:doctor` and the drive you ran - Evidence: command logs, screenshot paths, and trace paths that still exist after cleanup - Results: pass or fail per command, plus the observable end state (URL, heading, file written, record read back, attribute) -- INCONCLUSIVE: any step that could not run, and the concrete reason (port busy, revalidate secret unset, browser library missing). A failed ingest is a failure, not `INCONCLUSIVE`. +- INCONCLUSIVE: any step that could not run, and the concrete reason (port busy, revalidate secret unset, browser library missing, ingest network unreachable). Any other ingest failure is a failure. ``` A missing check is `INCONCLUSIVE`. Do not describe it as passed. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index f703f90f..cf18f288 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -17,7 +17,8 @@ const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) and the listener pids recorded in /tmp/synac-verify/run.json, after checking -each pid's start time. Restores Convex and Next files this launch rewrote. +each pid's start time. Backs up regenerated Convex and Next files, restores +their worktree copies, and warns about hand-maintained files. Leaves /tmp/synac-verify/evidence/ in place. `; diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index fb9e16ae..3d0b3921 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, copyFileSync, + cpSync, existsSync, lstatSync, mkdirSync, @@ -26,12 +27,10 @@ const nextEnvFile = path.join(root, 'apps/web/next-env.d.ts'); const nextEnvMeta = 'next-env.meta.json'; const nextEnvSnapshot = 'next-env.snapshot'; -const generatedFiles = new Set([ - 'skills-lock.json', +const handMaintained = new Set([ 'AGENTS.md', 'CLAUDE.md', 'apps/web/AGENTS.md', - 'apps/web/next-env.d.ts', 'convex.json', ]); export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? '/tmp/synac-verify'; @@ -54,7 +53,11 @@ const tmuxConfig = process.env.SYNAC_VERIFY_TMUX_CONFIG : ''; export function ensureState() { - mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + let created = false; + if (!existsSync(stateDir)) { + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + created = true; + } const listed = lstatSync(stateDir); if (listed.isSymbolicLink() || !listed.isDirectory()) { throw new Error(`${stateDir} is not a private directory`); @@ -63,12 +66,17 @@ export function ensureState() { if (uid !== null && listed.uid !== uid) { throw new Error(`${stateDir} is owned by uid ${listed.uid}, expected ${uid}`); } - chmodSync(stateDir, 0o700); + if (created) chmodSync(stateDir, 0o700); const mode = statSync(stateDir).mode & 0o777; if (mode !== 0o700) { - throw new Error(`${stateDir} mode is 0${mode.toString(8)}, expected 0700`); + throw new Error( + `${stateDir} mode is 0${mode.toString(8)}, expected 0700. Refusing to change an existing directory.`, + ); + } + if (!existsSync(evidenceDir)) { + mkdirSync(evidenceDir, { recursive: true, mode: 0o700 }); + chmodSync(evidenceDir, 0o700); } - mkdirSync(evidenceDir, { recursive: true, mode: 0o700 }); } export function readRun() { @@ -330,14 +338,30 @@ export function rememberListener(pidKey, port) { writeRun(run); } -function generatedDrift(rel) { - if (generatedFiles.has(rel)) return true; +function isHandMaintained(rel) { + return handMaintained.has(rel); +} + +function isRegenerated(rel) { + if (rel === 'skills-lock.json' || rel === 'apps/web/next-env.d.ts') return true; if (rel.startsWith('.agents/skills/convex')) return true; if (rel === 'convex/_generated/ai' || rel.startsWith('convex/_generated/ai/')) return true; if (rel === '.cursor/rules/convex_rules.mdc') return true; return false; } +function backupRel(rel) { + const abs = path.join(root, rel); + if (!existsSync(abs)) return null; + const listed = lstatSync(abs); + if (listed.isSymbolicLink()) return null; + const dest = path.join(stateDir, 'restored', rel); + mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); + if (listed.isDirectory()) cpSync(abs, dest, { recursive: true, dereference: false }); + else copyFileSync(abs, dest); + return dest; +} + function worktreeEntries() { const result = spawnSync('git', ['status', '--porcelain=v1', '-uall'], { cwd: root, @@ -377,15 +401,22 @@ function snapshotNextEnv() { function restoreNextEnv() { const metaPath = path.join(stateDir, nextEnvMeta); - if (!existsSync(metaPath)) return; + if (!existsSync(metaPath)) return null; const meta = JSON.parse(readFileSync(metaPath, 'utf8')); const snapPath = path.join(stateDir, nextEnvSnapshot); + const current = existsSync(nextEnvFile) ? readFileSync(nextEnvFile) : null; if (meta.existed !== true) { - if (existsSync(nextEnvFile)) rmSync(nextEnvFile, { force: true }); - return; + if (!current) return null; + const dest = backupRel('apps/web/next-env.d.ts'); + rmSync(nextEnvFile, { force: true }); + return dest; } - if (!existsSync(snapPath)) return; + if (!existsSync(snapPath)) return null; + const previous = readFileSync(snapPath); + if (current && current.equals(previous)) return null; + const dest = current ? backupRel('apps/web/next-env.d.ts') : null; copyFileSync(snapPath, nextEnvFile); + return dest; } export function rememberWorktree() { @@ -418,7 +449,7 @@ function removeUntracked(rel) { rmSync(abs, { force: true }); } let parent = path.dirname(cleaned); - while (parent && parent !== '.' && generatedDrift(parent)) { + while (parent && parent !== '.' && isRegenerated(parent)) { const tracked = spawnSync('git', ['ls-files', '--', parent], { cwd: root, encoding: 'utf8', @@ -439,28 +470,43 @@ function removeUntracked(rel) { export function restoreGeneratedDrift() { const run = readRun(); - if (!Array.isArray(run.dirtyBefore)) return []; + if (!Array.isArray(run.dirtyBefore)) return { restored: [], warned: [], backups: [] }; const before = new Set(run.dirtyBefore); const restore = []; const remove = []; + const warned = []; for (const entry of worktreeEntries()) { - if (before.has(entry.rel) || !generatedDrift(entry.rel)) continue; + if (before.has(entry.rel)) continue; + if (isHandMaintained(entry.rel)) { + warned.push(entry.rel); + continue; + } + if (!isRegenerated(entry.rel)) continue; if (entry.code === '??') remove.push(entry.rel); else restore.push(entry.rel); } + const backups = []; + for (const rel of [...restore, ...remove]) { + const dest = backupRel(rel); + if (dest) backups.push({ rel, dest }); + } if (restore.length > 0) { - const result = spawnSync( - 'git', - ['restore', '--source=HEAD', '--worktree', '--staged', '--', ...restore], - { cwd: root, encoding: 'utf8' }, - ); + const result = spawnSync('git', ['restore', '--source=HEAD', '--worktree', '--', ...restore], { + cwd: root, + encoding: 'utf8', + }); if (result.status !== 0) { throw new Error(`git restore failed: ${result.stderr ?? ''}`.trim()); } } for (const rel of [...remove].sort((a, b) => b.length - a.length)) removeUntracked(rel); - restoreNextEnv(); - const touched = [...restore, ...remove]; - if (touched.length > 0) console.log(`restored generated files: ${touched.join(', ')}`); - return touched; + const nextEnvBackup = restoreNextEnv(); + if (nextEnvBackup) backups.push({ rel: 'apps/web/next-env.d.ts', dest: nextEnvBackup }); + for (const item of backups) console.log(`backed up ${item.rel} to ${item.dest}`); + const restored = [...restore, ...remove]; + if (restored.length > 0) console.log(`restored generated files: ${restored.join(', ')}`); + for (const rel of warned) { + console.warn(`warning: ${rel} changed during the run and was left in place`); + } + return { restored, warned, backups }; } diff --git a/AGENTS.md b/AGENTS.md index d72bdbc5..52fe75be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`npx convex dev` can rewrite Convex skill files, `skills-lock.json`, and `convex/_generated/ai/`. `next dev` can rewrite `AGENTS.md`, `apps/web/AGENTS.md`, and `apps/web/next-env.d.ts`. `pnpm verify:launch` and `pnpm verify:cleanup` put those paths back. Do not commit that rewrite. +`npx convex dev` can rewrite Convex skill files, `skills-lock.json`, and `convex/_generated/ai/`. `next dev` can rewrite `apps/web/next-env.d.ts`. `pnpm verify:launch` and `pnpm verify:cleanup` back those up under `/tmp/synac-verify/restored/` and restore the worktree copy. They warn, and do not revert, changes to `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, or `convex.json`. ## Cloud Agent notes diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index bbef21f3..c1b78ed3 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -4,6 +4,8 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. -This block is written and re-added by `next dev`. Verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. A verify run restores this file when `next dev` rewrites it. Do not commit that rewrite. +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + +`next dev` rewrites the marked block above. A verify run leaves that rewrite in place and prints a warning. Do not commit that rewrite with feature work. From f9ab70e96d8bea3773b8b7d2fd126b6a1eeb964d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:33:11 +0000 Subject: [PATCH 09/23] fix: report an unreachable ingest as inconclusive A network failure with no HTTP status stays INCONCLUSIVE. Any other ingest failure still fails the drive. Co-authored-by: Aman Thanvi --- .../references/features/README.md | 2 +- .../references/features/ingest.md | 4 +-- .agents/skills/verify-synac/scripts/cli.mjs | 27 +++++++++++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.agents/skills/verify-synac/references/features/README.md b/.agents/skills/verify-synac/references/features/README.md index 53ea6823..8be2c764 100644 --- a/.agents/skills/verify-synac/references/features/README.md +++ b/.agents/skills/verify-synac/references/features/README.md @@ -18,7 +18,7 @@ The relevant feature file is the coverage set. When a change crosses search, an - Exercise every reachable entry point the change can affect, including empty, error, and hostile inputs listed in Gotchas. - Record the action and the final observable state, including files written and records read back. - Mocks count only where the product already isolates an upstream fetch. The glossary reads the synced Convex dataset. Do not stub those reads. -- When a path is unreachable, name it and the concrete prerequisite. A live RFC ingest that does not finish fails the drive. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. +- When a path is unreachable, name it and the concrete prerequisite. A live RFC ingest that cannot reach the network is `INCONCLUSIVE`. Any other ingest failure fails the drive. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. ## Full sweep diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index cc5815da..084e9ab0 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,11 +45,11 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the upstream fetch cannot finish, `pnpm verify:drive` exits 1. Keep the log `cli-ingest-rfc4949.log`. Do not record that result as `INCONCLUSIVE`. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. An HTTP error, a parser error, or any other non-zero ingest is a failure and the drive exits 1. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas - `--all` walks every source and will try the network for each. The sweep uses `rfc4949` only. - A run with `SYNAC_CONTENT_DIR` unset writes `content/generated/` in the repo. The helper always sets the variable for the live fetch. - `unchanged` means the new bundle compared equal to the previous file. Confirm the isolated file still exists. The word alone is not the side effect. -- Adapters that need a secret or a blocked host fail the drive. The log has the error text. Do not vendor a fake RFC into product content to force a pass. +- A blocked host or a DNS failure is `INCONCLUSIVE` when the error is a network failure with no HTTP status. An HTTP error from the adapter is a failure. Do not vendor a fake RFC into product content to force a pass. diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index 049cde6c..24fd2dd9 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { evidenceDir, ensureState, root, runLogged, stateDir } from './runtime.mjs'; @@ -11,7 +12,8 @@ Runs the content CLI and checks side effects. --smoke content:check and content:compile (writes tools/content/dist/compiled.json) default smoke, plus content:history, a hostile content dir, ingest usage, - and an isolated rfc4949 ingest. A failed ingest exits 1. + and an isolated rfc4949 ingest. A network-unreachable ingest is + INCONCLUSIVE. Any other ingest failure exits 1. `; const compiledPath = path.join(root, 'tools/content/dist/compiled.json'); @@ -144,11 +146,30 @@ function ingestStep(report) { return; } const detail = text.trim().split('\n').slice(-8).join(' '); + if (ingestNetworkUnreachable(text)) { + report.steps.push({ + name: 'ingest-rfc4949', + status: 'INCONCLUSIVE', + reason: `network unreachable (exit ${result.status}). ${detail}`, + log: result.logPath, + }); + console.log(`INCONCLUSIVE ingest rfc4949: network unreachable. See ${result.logPath}`); + return; + } throw new Error( `ingest rfc4949 failed (exit ${result.status}). ${detail} See ${result.logPath}`, ); } +const httpFetchFailed = /fetch failed \(\d{3}\)/; +const networkUnreachable = + /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b/; + +export function ingestNetworkUnreachable(text) { + if (httpFetchFailed.test(text)) return false; + return networkUnreachable.test(text); +} + function main() { if (process.argv.includes('--help') || process.argv.includes('-h')) { process.stdout.write(help); @@ -178,4 +199,6 @@ function main() { console.log(`cli report ${path.join(evidenceDir, 'cli-report.json')}`); } -main(); +const invokedDirectly = + process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) main(); From b0d0daa9bafe320e0b0666e045f32232ff4c8643 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 12:48:34 +0000 Subject: [PATCH 10/23] fix: restore verify writes to the launch snapshot Launch records the hash, contents, and staged state of every file a verify run might regenerate. Cleanup and the next launch put a file back only when its bytes still match what that run wrote, and they write the snapshot rather than HEAD. The index is left alone. Any other change is kept and warned, after a backup of a restored file. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 23 +- .../skills/verify-synac/scripts/cleanup.mjs | 10 +- .../skills/verify-synac/scripts/launch.mjs | 12 +- .../skills/verify-synac/scripts/runtime.mjs | 394 ++++++++++++------ AGENTS.md | 2 +- apps/web/AGENTS.md | 2 +- 6 files changed, 285 insertions(+), 158 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index e50a335a..26029cb1 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -39,15 +39,16 @@ pnpm verify:launch `pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: 1. Exit 2 when Node is older than 24. -2. If doctor is `READY`, print `REUSED` and exit 0. -3. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. -4. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. -5. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. -6. Start tmux session `synac-verify-convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Stdin is `/dev/null`, so Convex does not run its interactive agent-file install. The session name is written to `run.json` as soon as tmux returns, before the ready check. A launch that times out still leaves the session for cleanup. Ready means the log line `Convex functions ready` in `/tmp/synac-verify/synac-verify-convex.log`. The listener pid is stored with its `/proc` start time once port 3210 is open. -7. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. -8. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. -9. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. -10. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The session is written to `run.json` before the port is open. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. +2. Restore files from the previous launch snapshot when their current bytes still match what that run wrote. If doctor is `READY`, print `REUSED` and exit 0 without taking a new snapshot. +3. Otherwise snapshot the hash, contents, and staged state of the watched files listed below, then continue. When launch returns, record the bytes that differ from that snapshot. Leave those bytes in the worktree until cleanup or the next launch. +4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. +5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. +6. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. +7. Start tmux session `synac-verify-convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Stdin is `/dev/null`, so Convex does not run its interactive agent-file install. The session name is written to `run.json` as soon as tmux returns, before the ready check. A launch that times out still leaves the session for cleanup. Ready means the log line `Convex functions ready` in `/tmp/synac-verify/synac-verify-convex.log`. The listener pid is stored with its `/proc` start time once port 3210 is open. +8. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. +9. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. +10. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. +11. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The session is written to `run.json` before the port is open. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. `docs/contributing/local-dev.md` is the same stack, split across terminals. Use the helper so the sessions are recorded for cleanup. @@ -61,7 +62,7 @@ Logs and state: `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -`npx convex dev` can rewrite `.agents/skills/convex*`, `convex/_generated/ai/`, and `skills-lock.json`, and it can add new skill directories. `next dev` can rewrite `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. Launch and cleanup copy those regenerated paths to `/tmp/synac-verify/restored/` and list each backup, then put the worktree copy back. They do not restore the git index. `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, and `convex.json` are hand-maintained. A change to one of those during the run is left in place and printed as a warning. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, and `skills-lock.json`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. When launch returns it records which watched files differ from the snapshot. Cleanup and the next launch restore a file only when its current bytes still equal that record, and they write the launch snapshot back (or delete the file when it was absent). They copy the current bytes to `/tmp/synac-verify/restored/` and list each backup before restoring. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -207,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It backs up regenerated Convex skill files, `skills-lock.json`, `convex/_generated/ai/`, and `apps/web/next-env.d.ts` under `/tmp/synac-verify/restored/`, lists those backups, and restores only the worktree. It warns and leaves `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, and `convex.json` in place. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It restores a watched file to the launch snapshot only when the current bytes still match what that run wrote. It copies those bytes to `/tmp/synac-verify/restored/` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index cf18f288..500207f5 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -6,7 +6,7 @@ import { ownedSessions, processIdentity, readRun, - restoreGeneratedDrift, + restoreOwnedWrites, sameProcess, sessionAlive, tmux, @@ -17,8 +17,10 @@ const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) and the listener pids recorded in /tmp/synac-verify/run.json, after checking -each pid's start time. Backs up regenerated Convex and Next files, restores -their worktree copies, and warns about hand-maintained files. +each pid's start time. Restores a watched file to the launch snapshot when +its current bytes still match what the run wrote. Backs that file up under +/tmp/synac-verify/restored/ first. Never changes the git index. Any other +change is left in place and printed as a warning. Leaves /tmp/synac-verify/evidence/ in place. `; @@ -85,7 +87,7 @@ function main() { } if (signaled.length > 0) console.log(`signaled pids ${signaled.join(', ')}`); - restoreGeneratedDrift(); + restoreOwnedWrites(); const cleared = readRun(); writeRun({ ...cleared, diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs index 15c6ecbe..fb1c51a5 100755 --- a/.agents/skills/verify-synac/scripts/launch.mjs +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -12,13 +12,13 @@ import { ensureState, evidenceDir, nodeMajor, + noteRunWrites, noteSession, portOpen, - readRun, rememberListener, - rememberWorktree, - restoreGeneratedDrift, + restoreOwnedWrites, root, + snapshotWatchedFiles, runLogged, sessionAlive, sessionConvex, @@ -78,7 +78,7 @@ async function main() { process.exitCode = exitCode; } finally { try { - restoreGeneratedDrift(); + noteRunWrites(); } catch (error) { console.error(error instanceof Error ? error.message : error); if (!process.exitCode) process.exitCode = 1; @@ -97,14 +97,14 @@ async function run() { } ensureState(); - if (Array.isArray(readRun().dirtyBefore)) restoreGeneratedDrift(); + restoreOwnedWrites(); const doctor = await runDoctor(baseUrl); if (doctor.status === 'READY') { console.log('REUSED'); console.log(`evidence ${evidenceDir}`); return; } - rememberWorktree(); + snapshotWatchedFiles(); const webUp = await portOpen(webPort); const convexUp = await portOpen(convexPort); diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 3d0b3921..0045ff7e 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -1,17 +1,19 @@ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { chmodSync, copyFileSync, - cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, + renameSync, rmdirSync, rmSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs'; import net from 'node:net'; @@ -23,16 +25,19 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); export const root = path.resolve(scriptDir, '../../../..'); if (process.cwd() !== root) process.chdir(root); -const nextEnvFile = path.join(root, 'apps/web/next-env.d.ts'); -const nextEnvMeta = 'next-env.meta.json'; -const nextEnvSnapshot = 'next-env.snapshot'; - -const handMaintained = new Set([ +const namedWatched = [ 'AGENTS.md', 'CLAUDE.md', 'apps/web/AGENTS.md', + 'apps/web/CLAUDE.md', 'convex.json', -]); + 'skills-lock.json', + 'apps/web/next-env.d.ts', + '.cursor/rules/convex_rules.mdc', +]; + +const snapshotDirName = 'snapshot'; +let tookSnapshot = false; export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? '/tmp/synac-verify'; export const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? path.join(stateDir, 'evidence'); @@ -338,123 +343,157 @@ export function rememberListener(pidKey, port) { writeRun(run); } -function isHandMaintained(rel) { - return handMaintained.has(rel); +function sha256Bytes(bytes) { + return createHash('sha256').update(bytes).digest('hex'); } -function isRegenerated(rel) { - if (rel === 'skills-lock.json' || rel === 'apps/web/next-env.d.ts') return true; - if (rel.startsWith('.agents/skills/convex')) return true; - if (rel === 'convex/_generated/ai' || rel.startsWith('convex/_generated/ai/')) return true; - if (rel === '.cursor/rules/convex_rules.mdc') return true; - return false; +function gitBlob(spec) { + const result = spawnSync('git', ['rev-parse', '-q', '--verify', spec], { + cwd: root, + encoding: 'utf8', + }); + if (result.status !== 0) return null; + const value = (result.stdout ?? '').trim(); + return value || null; } -function backupRel(rel) { +function stagedRecord(rel) { + const indexHash = gitBlob(`:${rel}`); + const headHash = gitBlob(`HEAD:${rel}`); + return { indexHash, headHash, staged: indexHash !== headHash }; +} + +function describeWorktree(rel) { const abs = path.join(root, rel); - if (!existsSync(abs)) return null; - const listed = lstatSync(abs); - if (listed.isSymbolicLink()) return null; - const dest = path.join(stateDir, 'restored', rel); - mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); - if (listed.isDirectory()) cpSync(abs, dest, { recursive: true, dereference: false }); - else copyFileSync(abs, dest); - return dest; + let listed; + try { + listed = lstatSync(abs); + } catch { + return { kind: 'absent', hash: null }; + } + if (listed.isSymbolicLink()) { + const target = readlinkSync(abs); + return { kind: 'symlink', hash: sha256Bytes(target), target }; + } + if (!listed.isFile()) return { kind: 'other', hash: null }; + return { kind: 'file', hash: sha256Bytes(readFileSync(abs)) }; } -function worktreeEntries() { - const result = spawnSync('git', ['status', '--porcelain=v1', '-uall'], { - cwd: root, - encoding: 'utf8', - }); - if (result.status !== 0) { - throw new Error(`git status failed: ${result.stderr ?? ''}`.trim()); - } - const entries = []; - for (const line of (result.stdout ?? '').split('\n')) { - if (!line) continue; - const code = line.slice(0, 2); - let rel = line.slice(3); - if (rel.startsWith('"') && rel.endsWith('"')) rel = rel.slice(1, -1); - if (rel.endsWith('/')) rel = rel.slice(0, -1); - if (rel.includes(' -> ')) { - const renamed = rel.split(' -> ').at(-1); - if (renamed) rel = renamed; +function walkFiles(dir, prefix) { + let names = []; + try { + names = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const rels = []; + for (const name of names) { + const rel = `${prefix}/${name.name}`; + const abs = path.join(dir, name.name); + if (name.isDirectory()) rels.push(...walkFiles(abs, rel)); + else if (name.isFile() || name.isSymbolicLink()) rels.push(rel); + } + return rels; +} + +function watchedRels() { + const rels = new Set(namedWatched); + const skills = path.join(root, '.agents/skills'); + let skillNames = []; + try { + skillNames = readdirSync(skills, { withFileTypes: true }); + } catch { + skillNames = []; + } + for (const name of skillNames) { + if (!name.isDirectory() || !name.name.startsWith('convex')) continue; + for (const rel of walkFiles(path.join(skills, name.name), `.agents/skills/${name.name}`)) { + rels.add(rel); } - entries.push({ code, rel }); } - return entries; + for (const rel of walkFiles(path.join(root, 'convex/_generated/ai'), 'convex/_generated/ai')) { + rels.add(rel); + } + return [...rels]; } -function snapshotNextEnv() { - ensureState(); - const metaPath = path.join(stateDir, nextEnvMeta); - const snapPath = path.join(stateDir, nextEnvSnapshot); - if (!existsSync(nextEnvFile)) { - writeFileSync(metaPath, `${JSON.stringify({ existed: false })}\n`); - rmSync(snapPath, { force: true }); - return; - } - copyFileSync(nextEnvFile, snapPath); - writeFileSync(metaPath, `${JSON.stringify({ existed: true })}\n`); -} - -function restoreNextEnv() { - const metaPath = path.join(stateDir, nextEnvMeta); - if (!existsSync(metaPath)) return null; - const meta = JSON.parse(readFileSync(metaPath, 'utf8')); - const snapPath = path.join(stateDir, nextEnvSnapshot); - const current = existsSync(nextEnvFile) ? readFileSync(nextEnvFile) : null; - if (meta.existed !== true) { - if (!current) return null; - const dest = backupRel('apps/web/next-env.d.ts'); - rmSync(nextEnvFile, { force: true }); - return dest; +function snapshotRoot() { + return path.join(stateDir, snapshotDirName); +} + +function manifestPath() { + return path.join(snapshotRoot(), 'manifest.json'); +} + +function writesPath() { + return path.join(snapshotRoot(), 'writes.json'); +} + +function contentPath(rel) { + return path.join(snapshotRoot(), 'files', rel); +} + +function readJson(file, fallback) { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return fallback; } - if (!existsSync(snapPath)) return null; - const previous = readFileSync(snapPath); - if (current && current.equals(previous)) return null; - const dest = current ? backupRel('apps/web/next-env.d.ts') : null; - copyFileSync(snapPath, nextEnvFile); - return dest; } -export function rememberWorktree() { - const run = readRun(); - run.dirtyBefore = worktreeEntries().map((entry) => entry.rel); - writeRun(run); - snapshotNextEnv(); +function sameDescribed(left, right) { + return left.kind === right.kind && left.hash === right.hash; } -function removeUntracked(rel) { - const cleaned = rel.replace(/\/+$/, ''); - const abs = path.join(root, cleaned); +function safeRel(rel) { + if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)) return false; + if (rel.includes('\\') || rel.split('/').includes('..')) return false; + return true; +} + +function backupRel(rel) { + const abs = path.join(root, rel); let listed; try { listed = lstatSync(abs); } catch { - return; + return null; } + const dest = path.join(stateDir, 'restored', rel); + mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); + rmSync(dest, { recursive: true, force: true }); if (listed.isSymbolicLink()) { - rmSync(abs, { force: true }); - } else if (listed.isDirectory()) { - const tracked = spawnSync('git', ['ls-files', '--', cleaned], { - cwd: root, - encoding: 'utf8', - }); - if ((tracked.stdout ?? '').trim().length > 0) return; - rmSync(abs, { recursive: true, force: true }); - return; - } else { - rmSync(abs, { force: true }); + symlinkSync(readlinkSync(abs), dest); + return dest; } - let parent = path.dirname(cleaned); - while (parent && parent !== '.' && isRegenerated(parent)) { - const tracked = spawnSync('git', ['ls-files', '--', parent], { - cwd: root, - encoding: 'utf8', - }); - if ((tracked.stdout ?? '').trim().length > 0) break; + if (listed.isDirectory()) return null; + if (!listed.isFile()) return null; + copyFileSync(abs, dest); + return dest; +} + +function absentRecord() { + return { + kind: 'absent', + hash: null, + indexHash: null, + headHash: null, + staged: false, + }; +} + +function trackedInGit(rel) { + const result = spawnSync('git', ['ls-files', '--', rel], { + cwd: root, + encoding: 'utf8', + }); + return (result.stdout ?? '').trim().length > 0; +} + +function pruneEmptyConvexSkillDirs(rel) { + let parent = path.dirname(rel); + while (parent && parent !== '.' && parent.startsWith('.agents/skills/convex')) { + if (trackedInGit(parent)) break; const absParent = path.join(root, parent); let names = []; try { @@ -468,45 +507,130 @@ function removeUntracked(rel) { } } -export function restoreGeneratedDrift() { - const run = readRun(); - if (!Array.isArray(run.dirtyBefore)) return { restored: [], warned: [], backups: [] }; - const before = new Set(run.dirtyBefore); - const restore = []; - const remove = []; - const warned = []; - for (const entry of worktreeEntries()) { - if (before.has(entry.rel)) continue; - if (isHandMaintained(entry.rel)) { - warned.push(entry.rel); - continue; +function placeSnapshot(rel, snap) { + const abs = path.join(root, rel); + let listed = null; + try { + listed = lstatSync(abs); + } catch { + listed = null; + } + if (listed?.isDirectory()) return false; + if (snap.kind === 'absent') { + if (!listed) return true; + rmSync(abs, { force: true }); + pruneEmptyConvexSkillDirs(rel); + return true; + } + if (snap.kind === 'symlink') { + if (typeof snap.target !== 'string') return false; + if (listed) rmSync(abs, { force: true }); + mkdirSync(path.dirname(abs), { recursive: true }); + symlinkSync(snap.target, abs); + return true; + } + if (snap.kind !== 'file') return false; + const src = contentPath(rel); + if (!existsSync(src)) return false; + if (listed?.isSymbolicLink()) rmSync(abs, { force: true }); + mkdirSync(path.dirname(abs), { recursive: true }); + copyFileSync(src, abs); + return true; +} + +export function snapshotWatchedFiles() { + ensureState(); + const staging = path.join(stateDir, `${snapshotDirName}.next`); + rmSync(staging, { recursive: true, force: true }); + mkdirSync(path.join(staging, 'files'), { recursive: true, mode: 0o700 }); + const manifest = {}; + for (const rel of watchedRels()) { + if (!safeRel(rel)) continue; + const described = describeWorktree(rel); + if (described.kind === 'file') { + const bytes = readFileSync(path.join(root, rel)); + described.hash = sha256Bytes(bytes); + const dest = path.join(staging, 'files', rel); + mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); + writeFileSync(dest, bytes); } - if (!isRegenerated(entry.rel)) continue; - if (entry.code === '??') remove.push(entry.rel); - else restore.push(entry.rel); + manifest[rel] = { ...described, ...stagedRecord(rel) }; + } + writeFileSync(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + writeFileSync(path.join(staging, 'writes.json'), '{}\n'); + const previous = path.join(stateDir, `${snapshotDirName}.prev`); + rmSync(previous, { recursive: true, force: true }); + if (existsSync(snapshotRoot())) renameSync(snapshotRoot(), previous); + renameSync(staging, snapshotRoot()); + rmSync(previous, { recursive: true, force: true }); + tookSnapshot = true; +} + +export function noteRunWrites() { + if (!tookSnapshot) return; + const manifest = readJson(manifestPath(), null); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return; + const writes = {}; + const rels = new Set([...Object.keys(manifest), ...watchedRels()]); + for (const rel of rels) { + if (!safeRel(rel)) continue; + const current = describeWorktree(rel); + const snap = manifest[rel]; + const snapDescribed = snap + ? { kind: snap.kind, hash: snap.hash ?? null } + : { kind: 'absent', hash: null }; + if (sameDescribed(current, snapDescribed)) continue; + writes[rel] = { kind: current.kind, hash: current.hash }; + } + writeFileSync(writesPath(), `${JSON.stringify(writes, null, 2)}\n`); +} + +export function restoreOwnedWrites() { + const manifest = readJson(manifestPath(), null); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + return { restored: [], warned: [], backups: [] }; } + const writes = readJson(writesPath(), {}); + const writeMap = writes && typeof writes === 'object' && !Array.isArray(writes) ? writes : {}; + const rels = new Set([...Object.keys(manifest), ...Object.keys(writeMap)]); + const restored = []; + const warned = []; const backups = []; - for (const rel of [...restore, ...remove]) { + for (const rel of rels) { + if (!safeRel(rel)) continue; + const snap = manifest[rel] ?? absentRecord(); + const liveStage = stagedRecord(rel); + const snapIndex = typeof snap.indexHash === 'string' ? snap.indexHash : null; + const snapStaged = snap.staged === true; + if (liveStage.indexHash !== snapIndex || liveStage.staged !== snapStaged) { + warned.push(rel); + console.warn(`warning: ${rel} staged state changed during the run and was left in place`); + continue; + } + const current = describeWorktree(rel); + if (sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) continue; + const write = writeMap[rel]; + const owned = + write && + typeof write === 'object' && + sameDescribed(current, { kind: write.kind, hash: write.hash ?? null }); + if (!owned) { + warned.push(rel); + console.warn(`warning: ${rel} changed during the run and was left in place`); + continue; + } const dest = backupRel(rel); - if (dest) backups.push({ rel, dest }); - } - if (restore.length > 0) { - const result = spawnSync('git', ['restore', '--source=HEAD', '--worktree', '--', ...restore], { - cwd: root, - encoding: 'utf8', - }); - if (result.status !== 0) { - throw new Error(`git restore failed: ${result.stderr ?? ''}`.trim()); + if (dest) { + backups.push({ rel, dest }); + console.log(`backed up ${rel} to ${dest}`); } - } - for (const rel of [...remove].sort((a, b) => b.length - a.length)) removeUntracked(rel); - const nextEnvBackup = restoreNextEnv(); - if (nextEnvBackup) backups.push({ rel: 'apps/web/next-env.d.ts', dest: nextEnvBackup }); - for (const item of backups) console.log(`backed up ${item.rel} to ${item.dest}`); - const restored = [...restore, ...remove]; - if (restored.length > 0) console.log(`restored generated files: ${restored.join(', ')}`); - for (const rel of warned) { - console.warn(`warning: ${rel} changed during the run and was left in place`); + if (!placeSnapshot(rel, snap)) { + warned.push(rel); + console.warn(`warning: ${rel} changed during the run and was left in place`); + continue; + } + restored.push(rel); + console.log(`restored ${rel} to launch snapshot`); } return { restored, warned, backups }; } diff --git a/AGENTS.md b/AGENTS.md index 52fe75be..5e19643d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`npx convex dev` can rewrite Convex skill files, `skills-lock.json`, and `convex/_generated/ai/`. `next dev` can rewrite `apps/web/next-env.d.ts`. `pnpm verify:launch` and `pnpm verify:cleanup` back those up under `/tmp/synac-verify/restored/` and restore the worktree copy. They warn, and do not revert, changes to `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, or `convex.json`. +`pnpm verify:launch` snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, and `apps/web/next-env.d.ts`. Cleanup and the next launch restore a file to that snapshot only when its bytes still match what the run wrote. They back up the current bytes under `/tmp/synac-verify/restored/` first and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index c1b78ed3..0794fa55 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -8,4 +8,4 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex -`next dev` rewrites the marked block above. A verify run leaves that rewrite in place and prints a warning. Do not commit that rewrite with feature work. +`next dev` rewrites the marked block above. A verify run restores this file to its launch snapshot when the bytes still match that rewrite, and prints a warning when the file changed in any other way. Do not commit a rewrite with feature work. From 662253c8894c7afe62627959c87fe60dbf7f5412 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:00:59 +0000 Subject: [PATCH 11/23] fix: classify ingest from the final thrown error The verify drive reads only the last thrown ingest error and its cause. An HTTP status is a failure. An earlier retry line no longer turns a later parser error into an inconclusive result. The CLI also follows the .cursor skill symlink before deciding it was invoked directly. Co-authored-by: Aman Thanvi --- .../references/features/README.md | 2 +- .../references/features/ingest.md | 4 +- .agents/skills/verify-synac/scripts/cli.mjs | 72 +++++++++++++++++-- .../skills/verify-synac/scripts/cli.test.mjs | 63 ++++++++++++++++ 4 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 .agents/skills/verify-synac/scripts/cli.test.mjs diff --git a/.agents/skills/verify-synac/references/features/README.md b/.agents/skills/verify-synac/references/features/README.md index 8be2c764..278e5755 100644 --- a/.agents/skills/verify-synac/references/features/README.md +++ b/.agents/skills/verify-synac/references/features/README.md @@ -18,7 +18,7 @@ The relevant feature file is the coverage set. When a change crosses search, an - Exercise every reachable entry point the change can affect, including empty, error, and hostile inputs listed in Gotchas. - Record the action and the final observable state, including files written and records read back. - Mocks count only where the product already isolates an upstream fetch. The glossary reads the synced Convex dataset. Do not stub those reads. -- When a path is unreachable, name it and the concrete prerequisite. A live RFC ingest that cannot reach the network is `INCONCLUSIVE`. Any other ingest failure fails the drive. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. +- When a path is unreachable, name it and the concrete prerequisite. A live RFC ingest is `INCONCLUSIVE` only when the final thrown error and its cause are a network failure with no HTTP status. Any HTTP status or other ingest failure fails the drive. `POST /api/v1/internal/revalidate` is `INCONCLUSIVE` when `SYNAC_REVALIDATE_SECRET` is unset. ## Full sweep diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index 084e9ab0..a2a0cf5d 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,11 +45,11 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. An HTTP error, a parser error, or any other non-zero ingest is a failure and the drive exits 1. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the final thrown error and its cause. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas - `--all` walks every source and will try the network for each. The sweep uses `rfc4949` only. - A run with `SYNAC_CONTENT_DIR` unset writes `content/generated/` in the repo. The helper always sets the variable for the live fetch. - `unchanged` means the new bundle compared equal to the previous file. Confirm the isolated file still exists. The word alone is not the side effect. -- A blocked host or a DNS failure is `INCONCLUSIVE` when the error is a network failure with no HTTP status. An HTTP error from the adapter is a failure. Do not vendor a fake RFC into product content to force a pass. +- A blocked host or a DNS failure is `INCONCLUSIVE` when the final thrown error and its cause are a network failure with no HTTP status. Any HTTP status is a failure. An earlier retry line does not change the class of the final error. Do not vendor a fake RFC into product content to force a pass. diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index 24fd2dd9..eac6751a 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,7 +13,9 @@ Runs the content CLI and checks side effects. --smoke content:check and content:compile (writes tools/content/dist/compiled.json) default smoke, plus content:history, a hostile content dir, ingest usage, and an isolated rfc4949 ingest. A network-unreachable ingest is - INCONCLUSIVE. Any other ingest failure exits 1. + INCONCLUSIVE when the final thrown error and its cause are a + network failure with no HTTP status. Any HTTP status, parser + error, or other failure exits 1. `; const compiledPath = path.join(root, 'tools/content/dist/compiled.json'); @@ -161,13 +163,63 @@ function ingestStep(report) { ); } -const httpFetchFailed = /fetch failed \(\d{3}\)/; const networkUnreachable = /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b/; +function isTopLevelError(line) { + if (/^\s/.test(line)) return false; + return ( + /^[✗✘]\s+\S+:\s+\S/.test(line) || + /^(?:Error|TypeError|SyntaxError|AggregateError): \S/.test(line) + ); +} + +function isCauseLine(line) { + return ( + /^\s+\S/.test(line) || + /^\[cause\]:/i.test(line) || + /^caused by:/i.test(line) || + /^cause:/i.test(line) + ); +} + +export function finalThrownError(text) { + const lines = text.split(/\r?\n/); + let start = -1; + for (let i = 0; i < lines.length; i += 1) { + if (isTopLevelError(lines[i] ?? '')) start = i; + } + if (start === -1) return ''; + const block = [lines[start] ?? '']; + for (let i = start + 1; i < lines.length; i += 1) { + const line = lines[i] ?? ''; + if (isCauseLine(line)) { + block.push(line); + continue; + } + if (line.trim() === '' && isCauseLine(lines[i + 1] ?? '')) { + block.push(line); + continue; + } + break; + } + return block.join('\n'); +} + +function hasHttpStatus(text) { + return ( + /\bfetch failed \(\d{3}\)/i.test(text) || + /\bHTTP\/\d+(?:\.\d+)?\s+\d{3}\b/.test(text) || + /\bHTTP\s+\d{3}\b/.test(text) || + /\bstatus(?:\s+code)?\s*[:=]?\s*\d{3}\b/i.test(text) + ); +} + export function ingestNetworkUnreachable(text) { - if (httpFetchFailed.test(text)) return false; - return networkUnreachable.test(text); + const errorText = finalThrownError(text); + if (!errorText) return false; + if (hasHttpStatus(errorText)) return false; + return networkUnreachable.test(errorText); } function main() { @@ -199,6 +251,14 @@ function main() { console.log(`cli report ${path.join(evidenceDir, 'cli-report.json')}`); } +function sameFile(left, right) { + try { + return realpathSync(left) === realpathSync(right); + } catch { + return false; + } +} + const invokedDirectly = - process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + process.argv[1] !== undefined && sameFile(process.argv[1], fileURLToPath(import.meta.url)); if (invokedDirectly) main(); diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs new file mode 100644 index 00000000..482b31a0 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { ingestNetworkUnreachable } from './cli.mjs'; + +test('ENOTFOUND on the final error is INCONCLUSIVE', () => { + const log = [ + '$ pnpm ingest', + 'exit 1', + '✗ rfc4949: getaddrinfo ENOTFOUND www.rfc-editor.org', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), true); +}); + +test('HTTP 404 on the final error is FAIL', () => { + const log = [ + '✗ rfc4949: RFC 4949 fetch failed (404) for https://www.rfc-editor.org/rfc/rfc4949.txt', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a parser error is FAIL', () => { + const log = "✗ rfc4949: Unexpected token '<' is not valid JSON"; + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a retry followed by a parser error is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'TypeError: fetch failed', + "✗ rfc4949: Unexpected token '<' is not valid JSON", + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a network cause on the final error is INCONCLUSIVE', () => { + const log = [ + '✗ rfc4949: TypeError: fetch failed', + ' [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), true); +}); + +test('an HTTP status in the final cause is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + '✗ rfc4949: TypeError: fetch failed', + ' [cause]: Error: fetch failed (503)', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('cli.mjs --help runs through the .cursor skill symlink', () => { + const result = spawnSync( + process.execPath, + ['.cursor/skills/verify-synac/scripts/cli.mjs', '--help'], + { cwd: fileURLToPath(new URL('../../../..', import.meta.url)), encoding: 'utf8' }, + ); + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage: node/); +}); From 545ff7ed0327fbc52e8e6678526497c82c62f599 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:00:59 +0000 Subject: [PATCH 12/23] fix: keep each verify backup in its own directory Cleanup stores a run's backups under restored//, including symlinks. An existing evidence directory whose mode is not 0700 is refused. .cursor/rules/convex_rules.mdc is restored with the other watched files when the bytes still match the run write. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 8 +- .../skills/verify-synac/scripts/cleanup.mjs | 5 +- .../skills/verify-synac/scripts/runtime.mjs | 56 ++++++++---- .../verify-synac/scripts/runtime.test.mjs | 90 +++++++++++++++++++ AGENTS.md | 2 +- package.json | 2 +- 6 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 .agents/skills/verify-synac/scripts/runtime.test.mjs diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 26029cb1..5a96f243 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -58,11 +58,11 @@ Logs and state: - `/tmp/synac-verify/run.json` lists sessions and listener pids this launch started. Each pid keeps the process start time and command line from `/proc`. - `/tmp/synac-verify/synac-verify-convex.log` - `/tmp/synac-verify/synac-verify-web.log` -- `/tmp/synac-verify/evidence/` is created at launch and kept by cleanup +- `/tmp/synac-verify/evidence/` is created mode `0700` at launch and kept by cleanup. If that directory already exists with another mode, launch exits and does not change its permissions. `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, and `skills-lock.json`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. When launch returns it records which watched files differ from the snapshot. Cleanup and the next launch restore a file only when its current bytes still equal that record, and they write the launch snapshot back (or delete the file when it was absent). They copy the current bytes to `/tmp/synac-verify/restored/` and list each backup before restoring. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. When launch returns it records which watched files differ from the snapshot. Cleanup and the next launch restore a watched file, including `.cursor/rules/convex_rules.mdc`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -200,7 +200,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. When the network is unreachable (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed` with no HTTP status), the step is `INCONCLUSIVE` and the drive continues. Any other non-zero ingest, including `fetch failed (404)` or a parser error, fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the final thrown error and its cause. That error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup @@ -208,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It restores a watched file to the launch snapshot only when the current bytes still match what that run wrote. It copies those bytes to `/tmp/synac-verify/restored/` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It restores a watched file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match what that run wrote. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index 500207f5..168f3628 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -19,8 +19,9 @@ Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web and the listener pids recorded in /tmp/synac-verify/run.json, after checking each pid's start time. Restores a watched file to the launch snapshot when its current bytes still match what the run wrote. Backs that file up under -/tmp/synac-verify/restored/ first. Never changes the git index. Any other -change is left in place and printed as a warning. +/tmp/synac-verify/restored// first, including when the path is a +symlink. Never changes the git index. Any other change is left in place +and printed as a warning. Leaves /tmp/synac-verify/evidence/ in place. `; diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 0045ff7e..4170476f 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -57,31 +57,38 @@ const tmuxConfig = process.env.SYNAC_VERIFY_TMUX_CONFIG ? '/exec-daemon/tmux.portal.conf' : ''; -export function ensureState() { - let created = false; - if (!existsSync(stateDir)) { - mkdirSync(stateDir, { recursive: true, mode: 0o700 }); - created = true; - } - const listed = lstatSync(stateDir); +function assertPrivateDir(dir, created) { + const listed = lstatSync(dir); if (listed.isSymbolicLink() || !listed.isDirectory()) { - throw new Error(`${stateDir} is not a private directory`); + throw new Error(`${dir} is not a private directory`); } const uid = typeof process.getuid === 'function' ? process.getuid() : null; if (uid !== null && listed.uid !== uid) { - throw new Error(`${stateDir} is owned by uid ${listed.uid}, expected ${uid}`); + throw new Error(`${dir} is owned by uid ${listed.uid}, expected ${uid}`); } - if (created) chmodSync(stateDir, 0o700); - const mode = statSync(stateDir).mode & 0o777; + if (created) chmodSync(dir, 0o700); + const mode = statSync(dir).mode & 0o777; if (mode !== 0o700) { throw new Error( - `${stateDir} mode is 0${mode.toString(8)}, expected 0700. Refusing to change an existing directory.`, + `${dir} mode is 0${mode.toString(8)}, expected 0700. Refusing to change an existing directory.`, ); } - if (!existsSync(evidenceDir)) { - mkdirSync(evidenceDir, { recursive: true, mode: 0o700 }); - chmodSync(evidenceDir, 0o700); +} + +function preparePrivateDir(dir) { + let created = false; + try { + lstatSync(dir); + } catch { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + created = true; } + assertPrivateDir(dir, created); +} + +export function ensureState() { + preparePrivateDir(stateDir); + preparePrivateDir(evidenceDir); } export function readRun() { @@ -451,6 +458,21 @@ function safeRel(rel) { return true; } +function backupRunDir() { + const idFile = path.join(snapshotRoot(), 'backup-id'); + let id = 'unknown'; + try { + const raw = readFileSync(idFile, 'utf8').trim(); + if (/^[\w.-]+$/.test(raw)) id = raw; + } catch { + id = 'unknown'; + } + const dir = path.join(stateDir, 'restored', id); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + return dir; +} + function backupRel(rel) { const abs = path.join(root, rel); let listed; @@ -459,7 +481,7 @@ function backupRel(rel) { } catch { return null; } - const dest = path.join(stateDir, 'restored', rel); + const dest = path.join(backupRunDir(), rel); mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); rmSync(dest, { recursive: true, force: true }); if (listed.isSymbolicLink()) { @@ -558,6 +580,8 @@ export function snapshotWatchedFiles() { } writeFileSync(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(path.join(staging, 'writes.json'), '{}\n'); + const backupId = new Date().toISOString().replace(/[:.]/g, '-'); + writeFileSync(path.join(staging, 'backup-id'), `${backupId}\n`); const previous = path.join(stateDir, `${snapshotDirName}.prev`); rmSync(previous, { recursive: true, force: true }); if (existsSync(snapshotRoot())) renameSync(snapshotRoot(), previous); diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs new file mode 100644 index 00000000..91a9d273 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, +} from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; + +const state = `/tmp/synac-verify-test-${process.pid}`; +rmSync(state, { recursive: true, force: true }); +mkdirSync(state, { recursive: true, mode: 0o700 }); +chmodSync(state, 0o700); +process.env.SYNAC_VERIFY_STATE_DIR = state; +process.env.VERIFY_EVIDENCE_DIR = path.join(state, 'evidence'); + +const { root, ensureState, snapshotWatchedFiles, noteRunWrites, restoreOwnedWrites } = + await import('./runtime.mjs'); + +const scratchDir = path.join(root, '.agents/skills/convex-backup-test'); +const linkRel = '.agents/skills/convex-backup-test/link.md'; +const linkAbs = path.join(root, linkRel); + +function backupId() { + return readFileSync(path.join(state, 'snapshot/backup-id'), 'utf8').trim(); +} + +test('each snapshot stores backups in its own directory, including symlinks', () => { + try { + snapshotWatchedFiles(); + const firstId = backupId(); + mkdirSync(scratchDir, { recursive: true }); + symlinkSync('target.txt', linkAbs); + noteRunWrites(); + const first = restoreOwnedWrites(); + const firstBackup = path.join(state, 'restored', firstId, linkRel); + assert.equal(first.restored.includes(linkRel), true); + assert.equal(lstatSync(firstBackup).isSymbolicLink(), true); + assert.equal(readlinkSync(firstBackup), 'target.txt'); + assert.equal(exists(linkAbs), false); + + snapshotWatchedFiles(); + const secondId = backupId(); + assert.notEqual(secondId, firstId); + mkdirSync(scratchDir, { recursive: true }); + symlinkSync('other.txt', linkAbs); + noteRunWrites(); + restoreOwnedWrites(); + const secondBackup = path.join(state, 'restored', secondId, linkRel); + assert.equal(lstatSync(secondBackup).isSymbolicLink(), true); + assert.equal(readlinkSync(secondBackup), 'other.txt'); + assert.equal(lstatSync(firstBackup).isSymbolicLink(), true); + assert.equal(readlinkSync(firstBackup), 'target.txt'); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test('an existing evidence directory with the wrong mode is refused', () => { + ensureState(); + const evidence = path.join(state, 'evidence'); + chmodSync(evidence, 0o755); + assert.throws(() => ensureState(), /evidence mode is 0755/); + assert.equal(statSync(evidence).mode & 0o777, 0o755); + chmodSync(evidence, 0o700); +}); + +function exists(file) { + try { + lstatSync(file); + return true; + } catch { + return false; + } +} + +test('runtime checks leave the worktree clean', () => { + rmSync(scratchDir, { recursive: true, force: true }); + const status = spawnSync('git', ['status', '--porcelain=v1', '--', linkRel], { + cwd: root, + encoding: 'utf8', + }); + assert.equal(status.stdout, ''); +}); diff --git a/AGENTS.md b/AGENTS.md index 5e19643d..bebb0b39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`pnpm verify:launch` snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, and `apps/web/next-env.d.ts`. Cleanup and the next launch restore a file to that snapshot only when its bytes still match what the run wrote. They back up the current bytes under `/tmp/synac-verify/restored/` first and never change the git index. Any other change is left in place and printed as a warning. +`pnpm verify:launch` snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Cleanup and the next launch restore each of those files, including `.cursor/rules/convex_rules.mdc`, to that snapshot only when its bytes still match what the run wrote. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes diff --git a/package.json b/package.json index 040349bf..bb518da6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "lint:convex": "eslint convex tests", "typecheck": "corepack pnpm -r typecheck", "typecheck:convex": "tsc -p convex/tsconfig.json --noEmit", - "test": "corepack pnpm -r --workspace-concurrency=1 test && corepack pnpm test:convex", + "test": "corepack pnpm -r --workspace-concurrency=1 test && corepack pnpm test:convex && node --test .agents/skills/verify-synac/scripts/cli.test.mjs .agents/skills/verify-synac/scripts/runtime.test.mjs", "test:convex": "vitest run -c vitest.convex.config.ts", "tagging:check": "corepack pnpm --dir experiments/tagging/synthetic-reference run check && corepack pnpm --dir experiments/tagging/production-backfill run check", "format": "corepack pnpm -r format", From 866923159edab0cb86378199ee8c401360572d3b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:08:06 +0000 Subject: [PATCH 13/23] fix: bind verify snapshots to the checkout that wrote them The snapshot manifest stores the repo root, and launch and cleanup refuse to restore when it differs. A live REUSED stack is left alone. Tool-owned files keep their mode, post-launch Convex skill files are recorded and warned, and hand-maintained edits stay in place. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 8 +- .../skills/verify-synac/scripts/cleanup.mjs | 4 + .../skills/verify-synac/scripts/launch.mjs | 4 +- .../skills/verify-synac/scripts/runtime.mjs | 106 ++++++++++++++---- .../verify-synac/scripts/runtime.test.mjs | 79 ++++++++++++- AGENTS.md | 2 +- 6 files changed, 175 insertions(+), 28 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 5a96f243..17d48b0a 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -39,8 +39,8 @@ pnpm verify:launch `pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: 1. Exit 2 when Node is older than 24. -2. Restore files from the previous launch snapshot when their current bytes still match what that run wrote. If doctor is `READY`, print `REUSED` and exit 0 without taking a new snapshot. -3. Otherwise snapshot the hash, contents, and staged state of the watched files listed below, then continue. When launch returns, record the bytes that differ from that snapshot. Leave those bytes in the worktree until cleanup or the next launch. +2. If the saved snapshot names a different repo root, exit and do not restore. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. +3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. When launch returns, record tool-owned bytes that differ from that snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Cleanup records tool writes that appear after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. 4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. 6. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. @@ -62,7 +62,7 @@ Logs and state: `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. When launch returns it records which watched files differ from the snapshot. Cleanup and the next launch restore a watched file, including `.cursor/rules/convex_rules.mdc`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch and cleanup refuse to restore when that path is different. When launch returns it records tool-owned files that differ from the snapshot, and it keeps each file mode so a restore puts the executable bit back. Cleanup records tool-owned writes that appear after launch returns and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -208,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It restores a watched file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match what that run wrote. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It refuses to continue when the snapshot repo root is not this checkout. It records tool-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index 168f3628..1acaea8f 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -2,7 +2,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { + assertSnapshotCheckout, evidenceDir, + noteRunWrites, ownedSessions, processIdentity, readRun, @@ -51,6 +53,7 @@ function main() { process.stdout.write(help); return; } + assertSnapshotCheckout(); const run = readRun(); const sessions = run.sessions.filter((name) => ownedSessions.has(name)); for (const name of sessions) { @@ -88,6 +91,7 @@ function main() { } if (signaled.length > 0) console.log(`signaled pids ${signaled.join(', ')}`); + noteRunWrites({ later: true }); restoreOwnedWrites(); const cleared = readRun(); writeRun({ diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs index fb1c51a5..60b524c1 100755 --- a/.agents/skills/verify-synac/scripts/launch.mjs +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -9,6 +9,7 @@ import { convexPort, convexSitePort, convexUrl, + assertSnapshotCheckout, ensureState, evidenceDir, nodeMajor, @@ -97,13 +98,14 @@ async function run() { } ensureState(); - restoreOwnedWrites(); + assertSnapshotCheckout(); const doctor = await runDoctor(baseUrl); if (doctor.status === 'READY') { console.log('REUSED'); console.log(`evidence ${evidenceDir}`); return; } + restoreOwnedWrites(); snapshotWatchedFiles(); const webUp = await portOpen(webPort); diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 4170476f..06aefb61 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -36,6 +36,13 @@ const namedWatched = [ '.cursor/rules/convex_rules.mdc', ]; +const handMaintained = new Set([ + 'AGENTS.md', + 'CLAUDE.md', + 'apps/web/CLAUDE.md', + 'convex.json', +]); + const snapshotDirName = 'snapshot'; let tookSnapshot = false; export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? '/tmp/synac-verify'; @@ -383,7 +390,11 @@ function describeWorktree(rel) { return { kind: 'symlink', hash: sha256Bytes(target), target }; } if (!listed.isFile()) return { kind: 'other', hash: null }; - return { kind: 'file', hash: sha256Bytes(readFileSync(abs)) }; + return { + kind: 'file', + hash: sha256Bytes(readFileSync(abs)), + mode: listed.mode & 0o777, + }; } function walkFiles(dir, prefix) { @@ -557,6 +568,7 @@ function placeSnapshot(rel, snap) { if (listed?.isSymbolicLink()) rmSync(abs, { force: true }); mkdirSync(path.dirname(abs), { recursive: true }); copyFileSync(src, abs); + if (typeof snap.mode === 'number') chmodSync(abs, snap.mode); return true; } @@ -565,7 +577,7 @@ export function snapshotWatchedFiles() { const staging = path.join(stateDir, `${snapshotDirName}.next`); rmSync(staging, { recursive: true, force: true }); mkdirSync(path.join(staging, 'files'), { recursive: true, mode: 0o700 }); - const manifest = {}; + const files = {}; for (const rel of watchedRels()) { if (!safeRel(rel)) continue; const described = describeWorktree(rel); @@ -576,8 +588,9 @@ export function snapshotWatchedFiles() { mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 }); writeFileSync(dest, bytes); } - manifest[rel] = { ...described, ...stagedRecord(rel) }; + files[rel] = { ...described, ...stagedRecord(rel) }; } + const manifest = { repoRoot: root, files }; writeFileSync(path.join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(path.join(staging, 'writes.json'), '{}\n'); const backupId = new Date().toISOString().replace(/[:.]/g, '-'); @@ -590,39 +603,93 @@ export function snapshotWatchedFiles() { tookSnapshot = true; } -export function noteRunWrites() { - if (!tookSnapshot) return; - const manifest = readJson(manifestPath(), null); - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return; - const writes = {}; - const rels = new Set([...Object.keys(manifest), ...watchedRels()]); +function readManifest() { + if (!existsSync(manifestPath())) return null; + const raw = readJson(manifestPath(), null); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('snapshot manifest is unreadable. Refusing to restore.'); + } + if (typeof raw.repoRoot !== 'string' || !raw.files || typeof raw.files !== 'object' || Array.isArray(raw.files)) { + throw new Error('snapshot manifest has no checkout identity. Refusing to restore.'); + } + if (raw.repoRoot !== root) { + throw new Error( + `snapshot repo root is ${raw.repoRoot}, this checkout is ${root}. Refusing to restore.`, + ); + } + return raw; +} + +export function assertSnapshotCheckout() { + readManifest(); +} + +function isNewConvexSkill(rel, snap) { + return rel.startsWith('.agents/skills/convex') && (!snap || snap.kind === 'absent'); +} + +export function noteRunWrites(options = {}) { + const later = options.later === true; + if (!later && !tookSnapshot) return; + const manifest = readManifest(); + if (!manifest) return; + const files = manifest.files; + const existing = later ? readJson(writesPath(), {}) : {}; + const writeMap = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {}; + const rels = new Set([...Object.keys(files), ...watchedRels()]); for (const rel of rels) { if (!safeRel(rel)) continue; const current = describeWorktree(rel); - const snap = manifest[rel]; + const snap = files[rel]; const snapDescribed = snap ? { kind: snap.kind, hash: snap.hash ?? null } : { kind: 'absent', hash: null }; - if (sameDescribed(current, snapDescribed)) continue; - writes[rel] = { kind: current.kind, hash: current.hash }; + if (handMaintained.has(rel)) { + delete writeMap[rel]; + if (!sameDescribed(current, snapDescribed)) { + console.warn(`warning: ${rel} changed during the run and was left in place`); + } + continue; + } + if (sameDescribed(current, snapDescribed)) { + delete writeMap[rel]; + continue; + } + const prior = writeMap[rel]; + const priorDescribed = + prior && typeof prior === 'object' + ? { kind: prior.kind, hash: prior.hash ?? null } + : null; + if (later && priorDescribed && !sameDescribed(current, priorDescribed)) continue; + if (isNewConvexSkill(rel, snap) && (!priorDescribed || !sameDescribed(current, priorDescribed))) { + console.warn(`warning: new convex skill file ${rel}`); + } + writeMap[rel] = { kind: current.kind, hash: current.hash }; } - writeFileSync(writesPath(), `${JSON.stringify(writes, null, 2)}\n`); + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); } export function restoreOwnedWrites() { - const manifest = readJson(manifestPath(), null); - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { - return { restored: [], warned: [], backups: [] }; - } + const manifest = readManifest(); + if (!manifest) return { restored: [], warned: [], backups: [] }; + const files = manifest.files; const writes = readJson(writesPath(), {}); const writeMap = writes && typeof writes === 'object' && !Array.isArray(writes) ? writes : {}; - const rels = new Set([...Object.keys(manifest), ...Object.keys(writeMap)]); + const rels = new Set([...Object.keys(files), ...Object.keys(writeMap)]); const restored = []; const warned = []; const backups = []; for (const rel of rels) { if (!safeRel(rel)) continue; - const snap = manifest[rel] ?? absentRecord(); + const snap = files[rel] ?? absentRecord(); + const current = describeWorktree(rel); + if (handMaintained.has(rel)) { + if (!sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) { + warned.push(rel); + console.warn(`warning: ${rel} changed during the run and was left in place`); + } + continue; + } const liveStage = stagedRecord(rel); const snapIndex = typeof snap.indexHash === 'string' ? snap.indexHash : null; const snapStaged = snap.staged === true; @@ -631,7 +698,6 @@ export function restoreOwnedWrites() { console.warn(`warning: ${rel} staged state changed during the run and was left in place`); continue; } - const current = describeWorktree(rel); if (sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) continue; const write = writeMap[rel]; const owned = diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index 91a9d273..ba8db6aa 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -9,6 +9,7 @@ import { rmSync, statSync, symlinkSync, + writeFileSync, } from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; @@ -20,8 +21,14 @@ chmodSync(state, 0o700); process.env.SYNAC_VERIFY_STATE_DIR = state; process.env.VERIFY_EVIDENCE_DIR = path.join(state, 'evidence'); -const { root, ensureState, snapshotWatchedFiles, noteRunWrites, restoreOwnedWrites } = - await import('./runtime.mjs'); +const { + root, + ensureState, + snapshotWatchedFiles, + noteRunWrites, + restoreOwnedWrites, + assertSnapshotCheckout, +} = await import('./runtime.mjs'); const scratchDir = path.join(root, '.agents/skills/convex-backup-test'); const linkRel = '.agents/skills/convex-backup-test/link.md'; @@ -62,6 +69,74 @@ test('each snapshot stores backups in its own directory, including symlinks', () } }); +test('snapshot records this checkout and restore refuses a different root', () => { + snapshotWatchedFiles(); + const manifestPath = path.join(state, 'snapshot/manifest.json'); + const original = readFileSync(manifestPath, 'utf8'); + const manifest = JSON.parse(original); + assert.equal(manifest.repoRoot, root); + assert.equal(typeof manifest.files, 'object'); + manifest.repoRoot = '/tmp/other-checkout'; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + assert.throws(() => assertSnapshotCheckout(), /Refusing to restore/); + assert.throws(() => restoreOwnedWrites(), /Refusing to restore/); + writeFileSync(manifestPath, original); + assertSnapshotCheckout(); +}); + +test('restore keeps the executable bit and leaves hand-maintained edits', () => { + const scriptRel = '.agents/skills/convex-backup-test/run.sh'; + const scriptAbs = path.join(root, scriptRel); + const agents = path.join(root, 'AGENTS.md'); + const agentsOriginal = readFileSync(agents); + try { + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(scriptAbs, '#!/bin/sh\necho snapshot\n'); + chmodSync(scriptAbs, 0o755); + snapshotWatchedFiles(); + writeFileSync(scriptAbs, '#!/bin/sh\necho edited\n'); + chmodSync(scriptAbs, 0o644); + writeFileSync(agents, Buffer.concat([agentsOriginal, Buffer.from('\nverify-human\n')])); + noteRunWrites(); + const result = restoreOwnedWrites(); + assert.equal(readFileSync(scriptAbs, 'utf8'), '#!/bin/sh\necho snapshot\n'); + assert.equal(statSync(scriptAbs).mode & 0o777, 0o755); + assert.equal(result.restored.includes(scriptRel), true); + assert.equal(readFileSync(agents).equals(agentsOriginal), false); + assert.equal(result.restored.includes('AGENTS.md'), false); + assert.equal(result.warned.includes('AGENTS.md'), true); + } finally { + writeFileSync(agents, agentsOriginal); + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + +test('a convex skill file created after launch is recorded and warned', () => { + const rel = '.agents/skills/convex-backup-test/SKILL.md'; + const abs = path.join(root, rel); + const warnings = []; + const orig = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + snapshotWatchedFiles(); + noteRunWrites(); + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(abs, 'after launch\n'); + noteRunWrites({ later: true }); + const writes = JSON.parse(readFileSync(path.join(state, 'snapshot/writes.json'), 'utf8')); + assert.equal(writes[rel].kind, 'file'); + assert.equal( + warnings.some((line) => line.includes(`new convex skill file ${rel}`)), + true, + ); + restoreOwnedWrites(); + assert.equal(exists(abs), false); + } finally { + console.warn = orig; + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + test('an existing evidence directory with the wrong mode is refused', () => { ensureState(); const evidence = path.join(state, 'evidence'); diff --git a/AGENTS.md b/AGENTS.md index bebb0b39..bbcc6b1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`pnpm verify:launch` snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Cleanup and the next launch restore each of those files, including `.cursor/rules/convex_rules.mdc`, to that snapshot only when its bytes still match what the run wrote. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. +`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Launch and cleanup refuse when the snapshot names a different checkout. A live stack that prints `REUSED` is not restored. Cleanup and a later launch restore tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes From f6c05045fb2bec33f1dcf49589a789074e105cc9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:12:35 +0000 Subject: [PATCH 14/23] fix: ignore a verify snapshot that has no checkout identity A manifest written before repo roots were stored is not a different checkout. Launch skips it and takes a new snapshot. A stored root that does not match this checkout is still refused. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- .agents/skills/verify-synac/scripts/runtime.mjs | 2 +- .agents/skills/verify-synac/scripts/runtime.test.mjs | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 17d48b0a..86e9eb0e 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -39,7 +39,7 @@ pnpm verify:launch `pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: 1. Exit 2 when Node is older than 24. -2. If the saved snapshot names a different repo root, exit and do not restore. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. +2. If the saved snapshot names a different repo root, exit and do not restore. A snapshot that does not name a repo root is ignored. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. 3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. When launch returns, record tool-owned bytes that differ from that snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Cleanup records tool writes that appear after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. 4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 06aefb61..df26d038 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -610,7 +610,7 @@ function readManifest() { throw new Error('snapshot manifest is unreadable. Refusing to restore.'); } if (typeof raw.repoRoot !== 'string' || !raw.files || typeof raw.files !== 'object' || Array.isArray(raw.files)) { - throw new Error('snapshot manifest has no checkout identity. Refusing to restore.'); + return null; } if (raw.repoRoot !== root) { throw new Error( diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index ba8db6aa..883ba405 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -76,6 +76,11 @@ test('snapshot records this checkout and restore refuses a different root', () = const manifest = JSON.parse(original); assert.equal(manifest.repoRoot, root); assert.equal(typeof manifest.files, 'object'); + const flat = { 'AGENTS.md': { kind: 'file', hash: 'abc', mode: 0o644 } }; + writeFileSync(manifestPath, `${JSON.stringify(flat)}\n`); + assertSnapshotCheckout(); + assert.deepEqual(restoreOwnedWrites(), { restored: [], warned: [], backups: [] }); + writeFileSync(manifestPath, original); manifest.repoRoot = '/tmp/other-checkout'; writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); assert.throws(() => assertSnapshotCheckout(), /Refusing to restore/); From 4b59b77e8f47728ac0e94c0ce52ff39ae070cf87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:35:30 +0000 Subject: [PATCH 15/23] fix: claim verify restores only from the tool step that wrote them Launch hashes watched files immediately before and after each Convex and Next step and records only the paths that step owns. Other edits are warned and left, including hand-maintained files. A snapshot from another checkout is warned and not restored. Cleanup sends SIGKILL only while the launch start time still matches, and a missing backup id gets its own directory. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 10 +- .../references/features/ingest.md | 2 +- .../skills/verify-synac/scripts/cleanup.mjs | 54 ++-- .agents/skills/verify-synac/scripts/cli.mjs | 12 +- .../skills/verify-synac/scripts/cli.test.mjs | 24 ++ .../skills/verify-synac/scripts/launch.mjs | 161 ++++++----- .../skills/verify-synac/scripts/runtime.mjs | 273 +++++++++++++++--- .../verify-synac/scripts/runtime.test.mjs | 255 +++++++++++++--- AGENTS.md | 2 +- 9 files changed, 604 insertions(+), 189 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 86e9eb0e..847d26aa 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -39,8 +39,8 @@ pnpm verify:launch `pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: 1. Exit 2 when Node is older than 24. -2. If the saved snapshot names a different repo root, exit and do not restore. A snapshot that does not name a repo root is ignored. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. -3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. When launch returns, record tool-owned bytes that differ from that snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Cleanup records tool writes that appear after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. +2. If the saved snapshot names a different repo root, warn and do not restore. A snapshot that does not name a repo root is ignored. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. +3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. Right after `npx convex dev` and `npx convex env set`, record only the Convex-owned paths that changed during that step (`skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`). Right after `next build` or the Next server start, record only `apps/web/AGENTS.md` and `apps/web/next-env.d.ts` when those changed during that step. Any other change is warned and left in place, including `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json`. Cleanup records generator-owned files that change after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. 4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. 6. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. @@ -62,7 +62,7 @@ Logs and state: `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch and cleanup refuse to restore when that path is different. When launch returns it records tool-owned files that differ from the snapshot, and it keeps each file mode so a restore puts the executable bit back. Cleanup records tool-owned writes that appear after launch returns and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch and cleanup warn and leave files in place when that path is different. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, and it keeps each file mode so a restore puts the executable bit back. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -200,7 +200,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the final thrown error and its cause. That error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the final thrown error and its cause. A final error is an unindented `Name: message` line or a `[Name: message]` line, plus its indented cause. That error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup @@ -208,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). A reused pid is skipped and named in the log. The same pid is signaled once when two ports share it. It does not kill processes by name. It refuses to continue when the snapshot repo root is not this checkout. It records tool-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot or the run state names a different checkout, it warns and does not restore or stop those processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index a2a0cf5d..cd615605 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,7 +45,7 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the final thrown error and its cause. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the final thrown error and its cause. That error is the last unindented `Name: message` line or `[Name: message]` line. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index 1acaea8f..62bf2154 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -1,17 +1,16 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from 'node:fs'; - import { assertSnapshotCheckout, evidenceDir, - noteRunWrites, + noteWritesSinceObserved, ownedSessions, - processIdentity, readRun, restoreOwnedWrites, - sameProcess, + runBelongsToCheckout, sessionAlive, + stopRecordedPid, tmux, + warnUnclaimedChanges, writeRun, } from './runtime.mjs'; @@ -19,8 +18,10 @@ const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) and the listener pids recorded in /tmp/synac-verify/run.json, after checking -each pid's start time. Restores a watched file to the launch snapshot when -its current bytes still match what the run wrote. Backs that file up under +each pid's start time. Sends SIGTERM, then SIGKILL only while that start +time still matches. A snapshot from another checkout is left in place. +Restores a watched file to the launch snapshot when its current bytes still +match a recorded tool write. Backs that file up under /tmp/synac-verify/restored// first, including when the path is a symlink. Never changes the git index. Any other change is left in place and printed as a warning. @@ -28,32 +29,16 @@ and printed as a warning. Leaves /tmp/synac-verify/evidence/ in place. `; -function childPids(pid) { - const file = `/proc/${pid}/task/${pid}/children`; - if (!existsSync(file)) return []; - return readFileSync(file, 'utf8') - .trim() - .split(/\s+/) - .map((part) => Number(part)) - .filter((child) => Number.isInteger(child) && child > 1); -} - -function killTree(pid) { - if (!Number.isInteger(pid) || pid <= 1) return; - for (const child of childPids(pid)) killTree(child); - try { - process.kill(pid, 'SIGTERM'); - } catch { - return; - } -} - function main() { if (process.argv.includes('--help') || process.argv.includes('-h')) { process.stdout.write(help); return; } assertSnapshotCheckout(); + if (!runBelongsToCheckout()) { + console.log(`evidence ${evidenceDir}`); + return; + } const run = readRun(); const sessions = run.sessions.filter((name) => ownedSessions.has(name)); for (const name of sessions) { @@ -77,21 +62,24 @@ function main() { if (!saved) continue; if (seen.has(saved.pid)) continue; seen.add(saved.pid); - const live = processIdentity(saved.pid); - if (!live) { + const outcome = stopRecordedPid(saved); + if (outcome === 'exited') { console.log(`pid ${saved.pid} (${name}) already exited`); continue; } - if (!sameProcess(saved, saved.pid)) { + if (outcome === 'reused' || outcome === 'reused-before-kill') { console.log(`skipped pid ${saved.pid} (${name}): it is no longer the process launch started`); continue; } - killTree(saved.pid); - signaled.push(saved.pid); + if (outcome === 'kill') { + console.log(`sent SIGKILL to pid ${saved.pid} (${name}) after SIGTERM`); + } + if (outcome === 'term' || outcome === 'kill') signaled.push(saved.pid); } if (signaled.length > 0) console.log(`signaled pids ${signaled.join(', ')}`); - noteRunWrites({ later: true }); + noteWritesSinceObserved(); + warnUnclaimedChanges(); restoreOwnedWrites(); const cleared = readRun(); writeRun({ diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index eac6751a..4e0261c8 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -166,12 +166,14 @@ function ingestStep(report) { const networkUnreachable = /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b/; +function isNamedError(line) { + return /^(?:\[[A-Za-z_][\w$]*|[A-Za-z_][\w$]*): \S/.test(line); +} + function isTopLevelError(line) { - if (/^\s/.test(line)) return false; - return ( - /^[✗✘]\s+\S+:\s+\S/.test(line) || - /^(?:Error|TypeError|SyntaxError|AggregateError): \S/.test(line) - ); + if (/^\s/.test(line) || line.length === 0) return false; + if (isCauseLine(line)) return false; + return /^[✗✘]\s+\S+:\s+\S/.test(line) || isNamedError(line); } function isCauseLine(line) { diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs index 482b31a0..12bab6c7 100644 --- a/.agents/skills/verify-synac/scripts/cli.test.mjs +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -52,6 +52,30 @@ test('an HTTP status in the final cause is FAIL', () => { assert.equal(ingestNetworkUnreachable(log), false); }); +test('RangeError on the final error is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'RangeError: Invalid array length', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a custom error on the final error is FAIL', () => { + const log = [ + 'retry 1 failed: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'AdapterError: parser failed', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + +test('a bracketed Error after a fetch-failed retry is FAIL', () => { + const log = [ + 'retry 1 failed: TypeError: fetch failed', + "[Error: Unexpected token '<' is not valid JSON]", + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + test('cli.mjs --help runs through the .cursor skill symlink', () => { const result = spawnSync( process.execPath, diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs index 60b524c1..8cdc3e70 100755 --- a/.agents/skills/verify-synac/scripts/launch.mjs +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -10,16 +10,17 @@ import { convexSitePort, convexUrl, assertSnapshotCheckout, + beginLaunch, + captureToolWrites, + markStepBaseline, ensureState, evidenceDir, + finishLaunchSnapshot, nodeMajor, - noteRunWrites, noteSession, portOpen, rememberListener, - restoreOwnedWrites, root, - snapshotWatchedFiles, runLogged, sessionAlive, sessionConvex, @@ -79,7 +80,7 @@ async function main() { process.exitCode = exitCode; } finally { try { - noteRunWrites(); + finishLaunchSnapshot(); } catch (error) { console.error(error instanceof Error ? error.message : error); if (!process.exitCode) process.exitCode = 1; @@ -87,6 +88,19 @@ async function main() { } } +async function captureAfter(tool, fn) { + markStepBaseline(); + try { + return await fn(); + } finally { + try { + captureToolWrites(tool); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + } + } +} + async function run() { if (nodeMajor() < 24) { fail( @@ -100,13 +114,12 @@ async function run() { ensureState(); assertSnapshotCheckout(); const doctor = await runDoctor(baseUrl); - if (doctor.status === 'READY') { + const phase = beginLaunch(doctor.status); + if (phase === 'reuse') { console.log('REUSED'); console.log(`evidence ${evidenceDir}`); return; } - restoreOwnedWrites(); - snapshotWatchedFiles(); const webUp = await portOpen(webPort); const convexUp = await portOpen(convexPort); @@ -149,41 +162,45 @@ async function run() { } if (!convexUp) { - const log = path.join(stateDir, 'synac-verify-convex.log'); - startSession( - sessionConvex, - `${exportPrefix()} export CONVEX_AGENT_MODE=anonymous; cd ${shQuote(root)}; npx convex dev < /dev/null > ${shQuote(log)} 2>&1`, - ); - noteSession(sessionConvex); - await waitFor( - 'Convex functions ready', - async () => { - if (await portOpen(convexPort)) rememberListener('convex', convexPort); - try { - return readFileSync(log, 'utf8').includes('Convex functions ready'); - } catch { - return false; - } - }, - 180_000, - ); - if (!(await portOpen(convexPort))) fail(`convex log went ready but port ${convexPort} is closed`); - rememberListener('convex', convexPort); - if (await portOpen(convexSitePort)) rememberListener('convexSite', convexSitePort); + await captureAfter('convex', async () => { + const log = path.join(stateDir, 'synac-verify-convex.log'); + startSession( + sessionConvex, + `${exportPrefix()} export CONVEX_AGENT_MODE=anonymous; cd ${shQuote(root)}; npx convex dev < /dev/null > ${shQuote(log)} 2>&1`, + ); + noteSession(sessionConvex); + await waitFor( + 'Convex functions ready', + async () => { + if (await portOpen(convexPort)) rememberListener('convex', convexPort); + try { + return readFileSync(log, 'utf8').includes('Convex functions ready'); + } catch { + return false; + } + }, + 180_000, + ); + if (!(await portOpen(convexPort))) fail(`convex log went ready but port ${convexPort} is closed`); + rememberListener('convex', convexPort); + if (await portOpen(convexSitePort)) rememberListener('convexSite', convexSitePort); + }); } - let envSet = 1; - for (let attempt = 1; attempt <= 5; attempt += 1) { - const result = spawnSync( - 'npx', - ['convex', 'env', 'set', 'SYNAC_CONVEX_SERVICE_KEY', 'local-dev'], - { cwd: root, stdio: 'inherit', env: { ...process.env, CONVEX_AGENT_MODE: 'anonymous' } }, - ); - envSet = result.status ?? 1; - if (envSet === 0) break; - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - if (envSet !== 0) fail('npx convex env set SYNAC_CONVEX_SERVICE_KEY failed'); + await captureAfter('convex', async () => { + let envSet = 1; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const result = spawnSync( + 'npx', + ['convex', 'env', 'set', 'SYNAC_CONVEX_SERVICE_KEY', 'local-dev'], + { cwd: root, stdio: 'inherit', env: { ...process.env, CONVEX_AGENT_MODE: 'anonymous' } }, + ); + envSet = result.status ?? 1; + if (envSet === 0) break; + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + if (envSet !== 0) fail('npx convex env set SYNAC_CONVEX_SERVICE_KEY failed'); + }); const sync = runLogged( 'pnpm', @@ -200,40 +217,44 @@ async function run() { const buildId = path.join(root, 'apps/web/.next/BUILD_ID'); const skipBuild = mode === 'dev' || (process.env.VERIFY_SKIP_BUILD === '1' && existsSync(buildId)); if (!skipBuild) { - const build = spawnSync('pnpm', ['--filter', '@synac/web', 'build'], { - cwd: root, - stdio: 'inherit', - env: { ...process.env, ...webEnv }, + await captureAfter('next', async () => { + const build = spawnSync('pnpm', ['--filter', '@synac/web', 'build'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, ...webEnv }, + }); + if (build.status !== 0) fail('web build failed'); }); - if (build.status !== 0) fail('web build failed'); } if (!(await portOpen(webPort))) { - const log = path.join(stateDir, 'synac-verify-web.log'); - const webCommand = - mode === 'dev' - ? 'pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000' - : 'pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000'; - startSession( - sessionWeb, - `${exportPrefix()} cd ${shQuote(root)}; ${webCommand} > ${shQuote(log)} 2>&1`, - ); - noteSession(sessionWeb); - await waitFor( - 'web port', - async () => { - if (await portOpen(webPort)) { - rememberListener('web', webPort); - return true; - } - if (!sessionAlive(sessionWeb)) { - throw new Error(`web session exited before port ${webPort} opened. See ${log}`); - } - return false; - }, - 120_000, - ); - rememberListener('web', webPort); + await captureAfter('next', async () => { + const log = path.join(stateDir, 'synac-verify-web.log'); + const webCommand = + mode === 'dev' + ? 'pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000' + : 'pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000'; + startSession( + sessionWeb, + `${exportPrefix()} cd ${shQuote(root)}; ${webCommand} > ${shQuote(log)} 2>&1`, + ); + noteSession(sessionWeb); + await waitFor( + 'web port', + async () => { + if (await portOpen(webPort)) { + rememberListener('web', webPort); + return true; + } + if (!sessionAlive(sessionWeb)) { + throw new Error(`web session exited before port ${webPort} opened. See ${log}`); + } + return false; + }, + 120_000, + ); + rememberListener('web', webPort); + }); } let ready = await runDoctor(baseUrl); diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index df26d038..7b81916c 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -22,7 +22,7 @@ import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -export const root = path.resolve(scriptDir, '../../../..'); +export const root = path.resolve(process.env.SYNAC_VERIFY_ROOT ?? path.resolve(scriptDir, '../../../..')); if (process.cwd() !== root) process.chdir(root); const namedWatched = [ @@ -116,6 +116,7 @@ export function readRun() { dirtyBefore: Array.isArray(value.dirtyBefore) ? value.dirtyBefore.filter((item) => typeof item === 'string') : undefined, + repoRoot: typeof value.repoRoot === 'string' ? value.repoRoot : '', }; } catch { return emptyRun(); @@ -129,6 +130,7 @@ function emptyRun() { startedAt: '', webMode: 'start', baseUrl, + repoRoot: '', }; } @@ -144,7 +146,8 @@ function normalizePid(value) { export function writeRun(run) { ensureState(); - writeFileSync(runFile, `${JSON.stringify(run, null, 2)}\n`); + const next = { ...run, repoRoot: typeof run.repoRoot === 'string' && run.repoRoot ? run.repoRoot : root }; + writeFileSync(runFile, `${JSON.stringify(next, null, 2)}\n`); } export function shQuote(value) { @@ -312,6 +315,7 @@ export function noteSession(session) { run.baseUrl = baseUrl; run.webMode = process.env.VERIFY_WEB_MODE === 'dev' ? 'dev' : 'start'; if (!run.startedAt) run.startedAt = new Date().toISOString(); + run.repoRoot = root; writeRun(run); } @@ -347,6 +351,45 @@ export function sameProcess(saved, pid) { return false; } +function childPids(pid) { + const file = `/proc/${pid}/task/${pid}/children`; + if (!existsSync(file)) return []; + return readFileSync(file, 'utf8') + .trim() + .split(/\s+/) + .map((part) => Number(part)) + .filter((child) => Number.isInteger(child) && child > 1); +} + +function signalTree(pid, signal) { + for (const child of childPids(pid)) signalTree(child, signal); + try { + process.kill(pid, signal); + } catch { + return; + } +} + +function sleepMs(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +export function stopRecordedPid(saved, graceMs = 2000) { + if (!saved || !Number.isInteger(saved.pid) || saved.pid <= 1) return 'skip'; + if (!processIdentity(saved.pid)) return 'exited'; + if (!sameProcess(saved, saved.pid)) return 'reused'; + signalTree(saved.pid, 'SIGTERM'); + const deadline = Date.now() + graceMs; + while (Date.now() < deadline) { + if (!processIdentity(saved.pid)) return 'term'; + sleepMs(50); + } + if (!processIdentity(saved.pid)) return 'term'; + if (!sameProcess(saved, saved.pid)) return 'reused-before-kill'; + signalTree(saved.pid, 'SIGKILL'); + return 'kill'; +} + export function rememberListener(pidKey, port) { const pid = listenerPids(port)[0]; if (!pid) return; @@ -469,21 +512,37 @@ function safeRel(rel) { return true; } -function backupRunDir() { +function readBackupId() { const idFile = path.join(snapshotRoot(), 'backup-id'); - let id = 'unknown'; try { const raw = readFileSync(idFile, 'utf8').trim(); - if (/^[\w.-]+$/.test(raw)) id = raw; + if (/^[\w.-]+$/.test(raw) && raw !== 'unknown') return raw; } catch { - id = 'unknown'; + return null; } + return null; +} + +let restoreBackupDir = null; + +function beginRestoreBackup() { + const id = readBackupId() ?? `restore-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; const dir = path.join(stateDir, 'restored', id); mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); + restoreBackupDir = dir; return dir; } +function endRestoreBackup() { + restoreBackupDir = null; +} + +function backupRunDir() { + if (!restoreBackupDir) beginRestoreBackup(); + return restoreBackupDir; +} + function backupRel(rel) { const abs = path.join(root, rel); let listed; @@ -601,8 +660,11 @@ export function snapshotWatchedFiles() { renameSync(staging, snapshotRoot()); rmSync(previous, { recursive: true, force: true }); tookSnapshot = true; + stepBaseline = null; } +let warnedForeignRoot = null; + function readManifest() { if (!existsSync(manifestPath())) return null; const raw = readJson(manifestPath(), null); @@ -613,10 +675,15 @@ function readManifest() { return null; } if (raw.repoRoot !== root) { - throw new Error( - `snapshot repo root is ${raw.repoRoot}, this checkout is ${root}. Refusing to restore.`, - ); + if (warnedForeignRoot !== raw.repoRoot) { + warnedForeignRoot = raw.repoRoot; + console.warn( + `warning: snapshot repo root is ${raw.repoRoot}, this checkout is ${root}. Leaving files in place.`, + ); + } + return null; } + warnedForeignRoot = null; return raw; } @@ -624,52 +691,184 @@ export function assertSnapshotCheckout() { readManifest(); } +export function runBelongsToCheckout() { + const run = readRun(); + if (run.repoRoot && run.repoRoot !== root) { + console.warn( + `warning: run state is for ${run.repoRoot}, this checkout is ${root}. Not stopping those processes.`, + ); + return false; + } + return true; +} + function isNewConvexSkill(rel, snap) { return rel.startsWith('.agents/skills/convex') && (!snap || snap.kind === 'absent'); } -export function noteRunWrites(options = {}) { - const later = options.later === true; - if (!later && !tookSnapshot) return; +function toolOwns(tool, rel) { + if (tool === 'convex') { + return ( + rel === 'skills-lock.json' || + rel === '.cursor/rules/convex_rules.mdc' || + rel.startsWith('.agents/skills/convex') || + rel.startsWith('convex/_generated/ai/') + ); + } + if (tool === 'next') { + return rel === 'apps/web/AGENTS.md' || rel === 'apps/web/next-env.d.ts'; + } + return false; +} + +function readWriteMap() { + const writes = readJson(writesPath(), {}); + if (!writes || typeof writes !== 'object' || Array.isArray(writes)) return {}; + return { ...writes }; +} + +function snapDescribed(files, rel) { + const snap = files[rel]; + return snap ? { kind: snap.kind, hash: snap.hash ?? null } : { kind: 'absent', hash: null }; +} + +function writeRecord(current, tool) { + return { kind: current.kind, hash: current.hash, tool }; +} + +let stepBaseline = null; + +function describedMap() { const manifest = readManifest(); - if (!manifest) return; - const files = manifest.files; - const existing = later ? readJson(writesPath(), {}) : {}; - const writeMap = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {}; - const rels = new Set([...Object.keys(files), ...watchedRels()]); + if (!manifest) return null; + const described = {}; + const rels = new Set([...Object.keys(manifest.files), ...watchedRels()]); for (const rel of rels) { if (!safeRel(rel)) continue; const current = describeWorktree(rel); - const snap = files[rel]; - const snapDescribed = snap - ? { kind: snap.kind, hash: snap.hash ?? null } - : { kind: 'absent', hash: null }; - if (handMaintained.has(rel)) { - delete writeMap[rel]; - if (!sameDescribed(current, snapDescribed)) { - console.warn(`warning: ${rel} changed during the run and was left in place`); - } - continue; - } - if (sameDescribed(current, snapDescribed)) { + described[rel] = { kind: current.kind, hash: current.hash }; + } + return { files: manifest.files, described }; +} + +export function markStepBaseline() { + const mapped = describedMap(); + stepBaseline = mapped ? mapped.described : null; +} + +export function captureToolWrites(tool) { + if (tool !== 'convex' && tool !== 'next') { + throw new Error(`unknown verify tool ${tool}`); + } + const before = stepBaseline; + const mapped = describedMap(); + if (!before || !mapped) return; + const { files, described } = mapped; + const writeMap = readWriteMap(); + for (const rel of Object.keys(described)) { + if (handMaintained.has(rel) || !toolOwns(tool, rel)) continue; + const current = described[rel]; + const priorStep = before[rel] ?? { kind: 'absent', hash: null }; + if (!current || sameDescribed(current, priorStep)) continue; + if (sameDescribed(current, snapDescribed(files, rel))) { delete writeMap[rel]; continue; } + if (isNewConvexSkill(rel, files[rel])) console.warn(`warning: new convex skill file ${rel}`); + writeMap[rel] = writeRecord(current, tool); + } + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); + stepBaseline = described; +} + +function observedPath() { + return path.join(snapshotRoot(), 'observed.json'); +} + +export function markObserved() { + if (!tookSnapshot) return; + const manifest = readManifest(); + if (!manifest) return; + const observed = {}; + const rels = new Set([...Object.keys(manifest.files), ...watchedRels()]); + for (const rel of rels) { + if (!safeRel(rel)) continue; + const current = describeWorktree(rel); + observed[rel] = { kind: current.kind, hash: current.hash }; + } + writeFileSync(observedPath(), `${JSON.stringify(observed, null, 2)}\n`); +} + +export function finishLaunchSnapshot() { + markObserved(); + warnUnclaimedChanges(); +} + +export function noteWritesSinceObserved() { + const manifest = readManifest(); + if (!manifest) return; + const observed = readJson(observedPath(), null); + if (!observed || typeof observed !== 'object' || Array.isArray(observed)) return; + const files = manifest.files; + const writeMap = readWriteMap(); + const rels = new Set([...Object.keys(files), ...watchedRels(), ...Object.keys(observed)]); + for (const rel of rels) { + if (!safeRel(rel) || handMaintained.has(rel)) continue; + const tool = toolOwns('convex', rel) ? 'convex' : toolOwns('next', rel) ? 'next' : null; + if (!tool) continue; + const current = describeWorktree(rel); + const seen = observed[rel]; + const seenDescribed = + seen && typeof seen === 'object' ? { kind: seen.kind, hash: seen.hash ?? null } : { kind: 'absent', hash: null }; + if (sameDescribed(current, seenDescribed)) continue; const prior = writeMap[rel]; const priorDescribed = - prior && typeof prior === 'object' - ? { kind: prior.kind, hash: prior.hash ?? null } - : null; - if (later && priorDescribed && !sameDescribed(current, priorDescribed)) continue; - if (isNewConvexSkill(rel, snap) && (!priorDescribed || !sameDescribed(current, priorDescribed))) { - console.warn(`warning: new convex skill file ${rel}`); - } - writeMap[rel] = { kind: current.kind, hash: current.hash }; + prior && typeof prior === 'object' ? { kind: prior.kind, hash: prior.hash ?? null } : null; + if (priorDescribed && !sameDescribed(current, priorDescribed)) continue; + if (isNewConvexSkill(rel, files[rel])) console.warn(`warning: new convex skill file ${rel}`); + writeMap[rel] = writeRecord(current, tool); } writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); } +export function warnUnclaimedChanges() { + const manifest = readManifest(); + if (!manifest) return; + const files = manifest.files; + const writeMap = readWriteMap(); + const rels = new Set([...Object.keys(files), ...watchedRels()]); + for (const rel of rels) { + if (!safeRel(rel)) continue; + const current = describeWorktree(rel); + if (sameDescribed(current, snapDescribed(files, rel))) continue; + const write = writeMap[rel]; + if ( + write && + typeof write === 'object' && + sameDescribed(current, { kind: write.kind, hash: write.hash ?? null }) + ) { + continue; + } + console.warn(`warning: ${rel} changed during the run and was left in place`); + } +} + +export function beginLaunch(doctorStatus) { + if (doctorStatus === 'READY') return 'reuse'; + restoreOwnedWrites(); + snapshotWatchedFiles(); + return 'snapshot'; +} + export function restoreOwnedWrites() { + try { + return restoreOwnedWritesInner(); + } finally { + endRestoreBackup(); + } +} + +function restoreOwnedWritesInner() { const manifest = readManifest(); if (!manifest) return { restored: [], warned: [], backups: [] }; const files = manifest.files; diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index 883ba405..21783d33 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, lstatSync, @@ -12,25 +12,65 @@ import { writeFileSync, } from 'node:fs'; import path from 'node:path'; -import { test } from 'node:test'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +const realRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const repo = `/tmp/synac-verify-repo-${process.pid}`; const state = `/tmp/synac-verify-test-${process.pid}`; +const scratchDir = path.join(repo, '.agents/skills/convex-backup-test'); + +function git(args) { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +} + +rmSync(repo, { recursive: true, force: true }); rmSync(state, { recursive: true, force: true }); -mkdirSync(state, { recursive: true, mode: 0o700 }); -chmodSync(state, 0o700); +mkdirSync(path.join(repo, 'apps/web'), { recursive: true }); +mkdirSync(path.join(repo, '.agents/skills'), { recursive: true }); +mkdirSync(path.join(repo, 'convex/_generated/ai'), { recursive: true }); +writeFileSync(path.join(repo, 'AGENTS.md'), 'agents\n'); +writeFileSync(path.join(repo, 'CLAUDE.md'), 'claude\n'); +writeFileSync(path.join(repo, 'apps/web/AGENTS.md'), 'web agents\n'); +writeFileSync(path.join(repo, 'apps/web/CLAUDE.md'), '@AGENTS.md\n'); +writeFileSync(path.join(repo, 'skills-lock.json'), '{}\n'); +writeFileSync(path.join(repo, 'convex/_generated/ai/guidelines.md'), 'guide\n'); +git(['init']); +git(['config', 'user.email', 'verify@example.com']); +git(['config', 'user.name', 'verify']); +git(['add', '.']); +git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'init']); + +process.env.SYNAC_VERIFY_ROOT = repo; process.env.SYNAC_VERIFY_STATE_DIR = state; process.env.VERIFY_EVIDENCE_DIR = path.join(state, 'evidence'); +// runtime.mjs binds the checkout and chdir at import, so this import stays +// after SYNAC_VERIFY_ROOT points at the temp repo. const { root, ensureState, snapshotWatchedFiles, - noteRunWrites, + captureToolWrites, + markStepBaseline, + markObserved, + noteWritesSinceObserved, restoreOwnedWrites, assertSnapshotCheckout, + beginLaunch, + runBelongsToCheckout, + writeRun, + readRun, + stopRecordedPid, + processIdentity, } = await import('./runtime.mjs'); -const scratchDir = path.join(root, '.agents/skills/convex-backup-test'); +after(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(state, { recursive: true, force: true }); +}); + const linkRel = '.agents/skills/convex-backup-test/link.md'; const linkAbs = path.join(root, linkRel); @@ -38,13 +78,23 @@ function backupId() { return readFileSync(path.join(state, 'snapshot/backup-id'), 'utf8').trim(); } +function exists(file) { + try { + lstatSync(file); + return true; + } catch { + return false; + } +} + test('each snapshot stores backups in its own directory, including symlinks', () => { try { snapshotWatchedFiles(); const firstId = backupId(); + markStepBaseline(); mkdirSync(scratchDir, { recursive: true }); symlinkSync('target.txt', linkAbs); - noteRunWrites(); + captureToolWrites('convex'); const first = restoreOwnedWrites(); const firstBackup = path.join(state, 'restored', firstId, linkRel); assert.equal(first.restored.includes(linkRel), true); @@ -55,9 +105,10 @@ test('each snapshot stores backups in its own directory, including symlinks', () snapshotWatchedFiles(); const secondId = backupId(); assert.notEqual(secondId, firstId); + markStepBaseline(); mkdirSync(scratchDir, { recursive: true }); symlinkSync('other.txt', linkAbs); - noteRunWrites(); + captureToolWrites('convex'); restoreOwnedWrites(); const secondBackup = path.join(state, 'restored', secondId, linkRel); assert.equal(lstatSync(secondBackup).isSymbolicLink(), true); @@ -69,49 +120,103 @@ test('each snapshot stores backups in its own directory, including symlinks', () } }); -test('snapshot records this checkout and restore refuses a different root', () => { +test('snapshot records this checkout and a different root is left in place', () => { snapshotWatchedFiles(); const manifestPath = path.join(state, 'snapshot/manifest.json'); const original = readFileSync(manifestPath, 'utf8'); const manifest = JSON.parse(original); assert.equal(manifest.repoRoot, root); - assert.equal(typeof manifest.files, 'object'); + assert.equal(manifest.files['apps/web/CLAUDE.md'].kind, 'file'); + assert.equal(manifest.files['.cursor/rules/convex_rules.mdc'].kind, 'absent'); const flat = { 'AGENTS.md': { kind: 'file', hash: 'abc', mode: 0o644 } }; writeFileSync(manifestPath, `${JSON.stringify(flat)}\n`); assertSnapshotCheckout(); assert.deepEqual(restoreOwnedWrites(), { restored: [], warned: [], backups: [] }); writeFileSync(manifestPath, original); + const lock = path.join(root, 'skills-lock.json'); + const before = readFileSync(lock, 'utf8'); + markStepBaseline(); + writeFileSync(lock, '{"tool":true}\n'); + captureToolWrites('convex'); manifest.repoRoot = '/tmp/other-checkout'; writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); - assert.throws(() => assertSnapshotCheckout(), /Refusing to restore/); - assert.throws(() => restoreOwnedWrites(), /Refusing to restore/); + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + assert.doesNotThrow(() => assertSnapshotCheckout()); + assert.deepEqual(restoreOwnedWrites(), { restored: [], warned: [], backups: [] }); + } finally { + console.warn = origWarn; + } + assert.equal(readFileSync(lock, 'utf8'), '{"tool":true}\n'); + assert.equal(warnings.some((line) => line.includes('Leaving files in place')), true); writeFileSync(manifestPath, original); + writeFileSync(lock, before); assertSnapshotCheckout(); }); -test('restore keeps the executable bit and leaves hand-maintained edits', () => { - const scriptRel = '.agents/skills/convex-backup-test/run.sh'; - const scriptAbs = path.join(root, scriptRel); +test('a hand edit survives and a tool write is restored', () => { const agents = path.join(root, 'AGENTS.md'); const agentsOriginal = readFileSync(agents); + const lock = path.join(root, 'skills-lock.json'); + const lockOriginal = readFileSync(lock); + const claude = path.join(root, 'apps/web/CLAUDE.md'); + const claudeOriginal = readFileSync(claude); + try { + snapshotWatchedFiles(); + writeFileSync(agents, Buffer.concat([agentsOriginal, Buffer.from('verify-human\n')])); + writeFileSync(lock, '{"human":true}\n'); + writeFileSync(claude, '@AGENTS.md\nverify-human\n'); + markStepBaseline(); + captureToolWrites('next'); + markObserved(); + noteWritesSinceObserved(); + const left = restoreOwnedWrites(); + assert.equal(readFileSync(agents, 'utf8').includes('verify-human'), true); + assert.equal(readFileSync(lock, 'utf8'), '{"human":true}\n'); + assert.equal(readFileSync(claude, 'utf8').includes('verify-human'), true); + assert.equal(left.restored.includes('AGENTS.md'), false); + assert.equal(left.restored.includes('skills-lock.json'), false); + assert.equal(left.restored.includes('apps/web/CLAUDE.md'), false); + assert.equal(left.warned.includes('AGENTS.md'), true); + assert.equal(left.warned.includes('skills-lock.json'), true); + + writeFileSync(agents, agentsOriginal); + writeFileSync(lock, lockOriginal); + writeFileSync(claude, claudeOriginal); + snapshotWatchedFiles(); + markStepBaseline(); + writeFileSync(lock, '{"tool":true}\n'); + captureToolWrites('convex'); + const restored = restoreOwnedWrites(); + assert.equal(readFileSync(lock, 'utf8'), lockOriginal.toString()); + assert.equal(restored.restored.includes('skills-lock.json'), true); + } finally { + writeFileSync(agents, agentsOriginal); + writeFileSync(lock, lockOriginal); + writeFileSync(claude, claudeOriginal); + } +}); + +test('restore keeps the executable bit and a reused stack is not restored', () => { + const scriptRel = '.agents/skills/convex-backup-test/run.sh'; + const scriptAbs = path.join(root, scriptRel); try { mkdirSync(scratchDir, { recursive: true }); writeFileSync(scriptAbs, '#!/bin/sh\necho snapshot\n'); chmodSync(scriptAbs, 0o755); snapshotWatchedFiles(); + markStepBaseline(); writeFileSync(scriptAbs, '#!/bin/sh\necho edited\n'); chmodSync(scriptAbs, 0o644); - writeFileSync(agents, Buffer.concat([agentsOriginal, Buffer.from('\nverify-human\n')])); - noteRunWrites(); - const result = restoreOwnedWrites(); + captureToolWrites('convex'); + assert.equal(beginLaunch('READY'), 'reuse'); + assert.equal(readFileSync(scriptAbs, 'utf8'), '#!/bin/sh\necho edited\n'); + assert.equal(beginLaunch('NOT_READY'), 'snapshot'); assert.equal(readFileSync(scriptAbs, 'utf8'), '#!/bin/sh\necho snapshot\n'); assert.equal(statSync(scriptAbs).mode & 0o777, 0o755); - assert.equal(result.restored.includes(scriptRel), true); - assert.equal(readFileSync(agents).equals(agentsOriginal), false); - assert.equal(result.restored.includes('AGENTS.md'), false); - assert.equal(result.warned.includes('AGENTS.md'), true); } finally { - writeFileSync(agents, agentsOriginal); rmSync(scratchDir, { recursive: true, force: true }); } }); @@ -124,12 +229,13 @@ test('a convex skill file created after launch is recorded and warned', () => { console.warn = (...args) => warnings.push(args.join(' ')); try { snapshotWatchedFiles(); - noteRunWrites(); + markObserved(); mkdirSync(scratchDir, { recursive: true }); writeFileSync(abs, 'after launch\n'); - noteRunWrites({ later: true }); + noteWritesSinceObserved(); const writes = JSON.parse(readFileSync(path.join(state, 'snapshot/writes.json'), 'utf8')); assert.equal(writes[rel].kind, 'file'); + assert.equal(writes[rel].tool, 'convex'); assert.equal( warnings.some((line) => line.includes(`new convex skill file ${rel}`)), true, @@ -142,6 +248,36 @@ test('a convex skill file created after launch is recorded and warned', () => { } }); +test('a missing backup id does not reuse one directory', () => { + const rel = '.agents/skills/convex-backup-test/extra.md'; + const abs = path.join(root, rel); + try { + snapshotWatchedFiles(); + markStepBaseline(); + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(abs, 'one\n'); + captureToolWrites('convex'); + rmSync(path.join(state, 'snapshot/backup-id')); + const first = restoreOwnedWrites(); + snapshotWatchedFiles(); + markStepBaseline(); + mkdirSync(scratchDir, { recursive: true }); + writeFileSync(abs, 'two\n'); + captureToolWrites('convex'); + rmSync(path.join(state, 'snapshot/backup-id')); + const second = restoreOwnedWrites(); + assert.equal(first.backups.length, 1); + assert.equal(second.backups.length, 1); + const firstDir = path.dirname(first.backups[0].dest); + const secondDir = path.dirname(second.backups[0].dest); + assert.notEqual(firstDir, secondDir); + assert.equal(firstDir.includes(`${path.sep}unknown`), false); + assert.equal(secondDir.includes(`${path.sep}unknown`), false); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } +}); + test('an existing evidence directory with the wrong mode is refused', () => { ensureState(); const evidence = path.join(state, 'evidence'); @@ -151,20 +287,65 @@ test('an existing evidence directory with the wrong mode is refused', () => { chmodSync(evidence, 0o700); }); -function exists(file) { +test('cleanup does not stop a run recorded for another checkout', () => { + const warnings = []; + const orig = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); try { - lstatSync(file); - return true; - } catch { - return false; + writeRun({ + ...readRun(), + repoRoot: '/tmp/other-checkout', + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + }); + assert.equal(runBelongsToCheckout(), false); + } finally { + console.warn = orig; } -} + assert.equal(warnings.some((line) => line.includes('Not stopping those processes')), true); + writeRun({ + ...readRun(), + repoRoot: root, + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + }); +}); -test('runtime checks leave the worktree clean', () => { - rmSync(scratchDir, { recursive: true, force: true }); - const status = spawnSync('git', ['status', '--porcelain=v1', '--', linkRel], { - cwd: root, - encoding: 'utf8', +test('SIGKILL waits for the launch start time to still match', async () => { + const child = spawn('bash', ['-c', 'trap "" TERM; while true; do sleep 30; done'], { + stdio: 'ignore', }); - assert.equal(status.stdout, ''); + try { + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + const saved = processIdentity(child.pid); + assert.ok(saved); + assert.notEqual(saved.startTime, ''); + const wrong = { ...saved, startTime: '1' }; + assert.equal(stopRecordedPid(wrong, 200), 'reused'); + assert.equal(processIdentity(child.pid)?.pid, child.pid); + assert.equal(stopRecordedPid(saved, 400), 'kill'); + const ended = await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + child.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal(ended.signal, 'SIGKILL'); + } finally { + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + } +}); + +test('runtime checks use the temp repo and leave the real checkout alone', () => { + rmSync(scratchDir, { recursive: true, force: true }); + assert.equal(root, repo); + assert.equal(exists(path.join(realRoot, '.agents/skills/convex-backup-test')), false); }); diff --git a/AGENTS.md b/AGENTS.md index bbcc6b1e..e65ccdf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Launch and cleanup refuse when the snapshot names a different checkout. A live stack that prints `REUSED` is not restored. Cleanup and a later launch restore tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. +`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Launch and cleanup warn and leave files in place when the snapshot names a different checkout. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes From 65c2ae1cd54f80f9bacf4053d1b0fa035da25424 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 13:43:25 +0000 Subject: [PATCH 16/23] fix: keep cleanup moving when the verify snapshot is not this checkout Launch refuses a foreign, legacy, or unreadable manifest and names the snapshot directory to remove. Cleanup still stops processes recorded for this checkout, copies that manifest aside, and skips only the file restore. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 6 +- .../skills/verify-synac/scripts/cleanup.mjs | 18 +-- .../skills/verify-synac/scripts/runtime.mjs | 100 ++++++++++++---- .../verify-synac/scripts/runtime.test.mjs | 108 ++++++++++++++---- AGENTS.md | 2 +- 5 files changed, 178 insertions(+), 56 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 847d26aa..a2f44090 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -39,7 +39,7 @@ pnpm verify:launch `pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: 1. Exit 2 when Node is older than 24. -2. If the saved snapshot names a different repo root, warn and do not restore. A snapshot that does not name a repo root is ignored. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. +2. If the saved snapshot names a different repo root, has no checkout identity, or cannot be read, exit and do not restore. The message names `/tmp/synac-verify/snapshot` and tells you to remove that directory or move it aside, then launch again. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. 3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. Right after `npx convex dev` and `npx convex env set`, record only the Convex-owned paths that changed during that step (`skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`). Right after `next build` or the Next server start, record only `apps/web/AGENTS.md` and `apps/web/next-env.d.ts` when those changed during that step. Any other change is warned and left in place, including `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json`. Cleanup records generator-owned files that change after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. 4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. @@ -62,7 +62,7 @@ Logs and state: `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch and cleanup warn and leave files in place when that path is different. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, and it keeps each file mode so a restore puts the executable bit back. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch refuses when that path is different, when the manifest has no checkout identity, or when the manifest cannot be read, and the error tells you to remove `/tmp/synac-verify/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout. It backs the foreign or legacy manifest up under `/tmp/synac-verify/restored//manifest.json`, warns, and skips the file restore. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, and it keeps each file mode so a restore puts the executable bit back. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -208,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot or the run state names a different checkout, it warns and does not restore or stop those processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot is foreign, legacy, or unreadable, it warns, copies that manifest aside, skips the file restore, and still stops processes recorded for this checkout. A run state from another checkout is not used to stop processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index 62bf2154..cd5fc012 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node import { - assertSnapshotCheckout, + beginCleanup, evidenceDir, noteWritesSinceObserved, ownedSessions, readRun, restoreOwnedWrites, - runBelongsToCheckout, sessionAlive, stopRecordedPid, tmux, @@ -19,7 +18,8 @@ const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) and the listener pids recorded in /tmp/synac-verify/run.json, after checking each pid's start time. Sends SIGTERM, then SIGKILL only while that start -time still matches. A snapshot from another checkout is left in place. +time still matches. A foreign or legacy snapshot is backed up and not restored. Processes +recorded for this checkout are still stopped. Restores a watched file to the launch snapshot when its current bytes still match a recorded tool write. Backs that file up under /tmp/synac-verify/restored// first, including when the path is a @@ -34,8 +34,8 @@ function main() { process.stdout.write(help); return; } - assertSnapshotCheckout(); - if (!runBelongsToCheckout()) { + const gate = beginCleanup(); + if (!gate.stopOwn) { console.log(`evidence ${evidenceDir}`); return; } @@ -78,9 +78,11 @@ function main() { } if (signaled.length > 0) console.log(`signaled pids ${signaled.join(', ')}`); - noteWritesSinceObserved(); - warnUnclaimedChanges(); - restoreOwnedWrites(); + if (gate.restore) { + noteWritesSinceObserved(); + warnUnclaimedChanges(); + restoreOwnedWrites(); + } const cleared = readRun(); writeRun({ ...cleared, diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 7b81916c..a0892b44 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -374,17 +374,32 @@ function sleepMs(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } +function processState(pid) { + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const end = stat.lastIndexOf(')'); + if (end === -1) return ''; + return stat.slice(end + 2).trim().split(/\s+/)[0] ?? ''; + } catch { + return ''; + } +} + +function deadProcess(pid) { + return !processIdentity(pid) || processState(pid) === 'Z'; +} + export function stopRecordedPid(saved, graceMs = 2000) { if (!saved || !Number.isInteger(saved.pid) || saved.pid <= 1) return 'skip'; - if (!processIdentity(saved.pid)) return 'exited'; + if (deadProcess(saved.pid)) return 'exited'; if (!sameProcess(saved, saved.pid)) return 'reused'; signalTree(saved.pid, 'SIGTERM'); const deadline = Date.now() + graceMs; while (Date.now() < deadline) { - if (!processIdentity(saved.pid)) return 'term'; + if (deadProcess(saved.pid)) return 'term'; sleepMs(50); } - if (!processIdentity(saved.pid)) return 'term'; + if (deadProcess(saved.pid)) return 'term'; if (!sameProcess(saved, saved.pid)) return 'reused-before-kill'; signalTree(saved.pid, 'SIGKILL'); return 'kill'; @@ -663,32 +678,71 @@ export function snapshotWatchedFiles() { stepBaseline = null; } -let warnedForeignRoot = null; - -function readManifest() { - if (!existsSync(manifestPath())) return null; - const raw = readJson(manifestPath(), null); - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error('snapshot manifest is unreadable. Refusing to restore.'); +function loadManifest() { + if (!existsSync(manifestPath())) return { status: 'missing' }; + let raw; + try { + raw = JSON.parse(readFileSync(manifestPath(), 'utf8')); + } catch { + return { status: 'unreadable' }; } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { status: 'unreadable' }; if (typeof raw.repoRoot !== 'string' || !raw.files || typeof raw.files !== 'object' || Array.isArray(raw.files)) { - return null; - } - if (raw.repoRoot !== root) { - if (warnedForeignRoot !== raw.repoRoot) { - warnedForeignRoot = raw.repoRoot; - console.warn( - `warning: snapshot repo root is ${raw.repoRoot}, this checkout is ${root}. Leaving files in place.`, - ); - } - return null; + return { status: 'legacy' }; } - warnedForeignRoot = null; - return raw; + if (raw.repoRoot !== root) return { status: 'foreign', repoRoot: raw.repoRoot }; + return { status: 'ok', raw }; +} + +function readManifest() { + const loaded = loadManifest(); + if (loaded.status !== 'ok') return null; + return loaded.raw; +} + +function clearSnapshotHint() { + return `Remove ${snapshotRoot()} or move it aside, then launch again.`; } export function assertSnapshotCheckout() { - readManifest(); + const loaded = loadManifest(); + if (loaded.status === 'missing' || loaded.status === 'ok') return; + if (loaded.status === 'foreign') { + throw new Error( + `snapshot repo root is ${loaded.repoRoot}, this checkout is ${root}. Refusing to launch. ${clearSnapshotHint()}`, + ); + } + if (loaded.status === 'legacy') { + throw new Error(`snapshot manifest has no checkout identity. Refusing to launch. ${clearSnapshotHint()}`); + } + throw new Error(`snapshot manifest is unreadable. Refusing to launch. ${clearSnapshotHint()}`); +} + +function backupRejectedManifest() { + const id = `manifest-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const dir = path.join(stateDir, 'restored', id); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const dest = path.join(dir, 'manifest.json'); + copyFileSync(manifestPath(), dest); + return dest; +} + +export function beginCleanup() { + const loaded = loadManifest(); + const stopOwn = runBelongsToCheckout(); + if (loaded.status === 'missing' || loaded.status === 'ok') { + return { restore: loaded.status === 'ok', stopOwn, backup: null }; + } + const dest = backupRejectedManifest(); + const why = + loaded.status === 'foreign' + ? `snapshot repo root is ${loaded.repoRoot}, this checkout is ${root}` + : loaded.status === 'legacy' + ? 'snapshot manifest has no checkout identity' + : 'snapshot manifest is unreadable'; + console.warn(`warning: ${why}. Leaving files in place. Backed up the manifest to ${dest}.`); + return { restore: false, stopOwn, backup: dest }; } export function runBelongsToCheckout() { diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index 21783d33..90fbe443 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -58,6 +58,7 @@ const { noteWritesSinceObserved, restoreOwnedWrites, assertSnapshotCheckout, + beginCleanup, beginLaunch, runBelongsToCheckout, writeRun, @@ -120,7 +121,7 @@ test('each snapshot stores backups in its own directory, including symlinks', () } }); -test('snapshot records this checkout and a different root is left in place', () => { +test('launch refuses a foreign or legacy manifest and says how to clear it', () => { snapshotWatchedFiles(); const manifestPath = path.join(state, 'snapshot/manifest.json'); const original = readFileSync(manifestPath, 'utf8'); @@ -128,32 +129,97 @@ test('snapshot records this checkout and a different root is left in place', () assert.equal(manifest.repoRoot, root); assert.equal(manifest.files['apps/web/CLAUDE.md'].kind, 'file'); assert.equal(manifest.files['.cursor/rules/convex_rules.mdc'].kind, 'absent'); - const flat = { 'AGENTS.md': { kind: 'file', hash: 'abc', mode: 0o644 } }; - writeFileSync(manifestPath, `${JSON.stringify(flat)}\n`); + const launch = path.join(realRoot, '.agents/skills/verify-synac/scripts/launch.mjs'); + const env = { + ...process.env, + SYNAC_VERIFY_ROOT: repo, + SYNAC_VERIFY_STATE_DIR: state, + VERIFY_EVIDENCE_DIR: path.join(state, 'evidence'), + }; + try { + writeFileSync(manifestPath, `${JSON.stringify({ 'AGENTS.md': { kind: 'file', hash: 'abc' } })}\n`); + assert.throws(() => assertSnapshotCheckout(), /no checkout identity/); + assert.throws(() => assertSnapshotCheckout(), /Remove .*snapshot/); + const legacy = spawnSync(process.execPath, [launch], { env, encoding: 'utf8' }); + assert.notEqual(legacy.status, 0); + assert.match(legacy.stderr, /Refusing to launch/); + assert.match(legacy.stderr, /Remove .*snapshot/); + const legacyGate = beginCleanup(); + assert.equal(legacyGate.restore, false); + assert.equal(legacyGate.stopOwn, true); + assert.equal(readFileSync(legacyGate.backup, 'utf8').includes('AGENTS.md'), true); + + manifest.repoRoot = '/tmp/other-checkout'; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + assert.throws(() => assertSnapshotCheckout(), /Refusing to launch/); + const foreign = spawnSync(process.execPath, [launch], { env, encoding: 'utf8' }); + assert.notEqual(foreign.status, 0); + assert.match(foreign.stderr, /\/tmp\/other-checkout/); + assert.match(foreign.stderr, /Remove .*snapshot/); + assert.equal(readFileSync(manifestPath, 'utf8').includes('/tmp/other-checkout'), true); + } finally { + writeFileSync(manifestPath, original); + } assertSnapshotCheckout(); - assert.deepEqual(restoreOwnedWrites(), { restored: [], warned: [], backups: [] }); - writeFileSync(manifestPath, original); +}); + +test('cleanup stops this checkout and only skips restore for a foreign manifest', async () => { + snapshotWatchedFiles(); + const manifestPath = path.join(state, 'snapshot/manifest.json'); + const original = readFileSync(manifestPath, 'utf8'); const lock = path.join(root, 'skills-lock.json'); const before = readFileSync(lock, 'utf8'); - markStepBaseline(); - writeFileSync(lock, '{"tool":true}\n'); - captureToolWrites('convex'); - manifest.repoRoot = '/tmp/other-checkout'; - writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); - const warnings = []; - const origWarn = console.warn; - console.warn = (...args) => warnings.push(args.join(' ')); + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); try { - assert.doesNotThrow(() => assertSnapshotCheckout()); - assert.deepEqual(restoreOwnedWrites(), { restored: [], warned: [], backups: [] }); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + const saved = processIdentity(child.pid); + writeFileSync(lock, '{"keep":true}\n'); + const manifest = JSON.parse(original); + manifest.repoRoot = '/tmp/other-checkout'; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + writeRun({ + ...readRun(), + repoRoot: root, + sessions: [], + pids: { convex: saved, web: null, convexSite: null }, + }); + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { + env: { + ...process.env, + SYNAC_VERIFY_ROOT: repo, + SYNAC_VERIFY_STATE_DIR: state, + VERIFY_EVIDENCE_DIR: path.join(state, 'evidence'), + }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, /Leaving files in place/); + assert.match(output, /Backed up the manifest to /); + assert.match(output, new RegExp(`signaled pids ${child.pid}`)); + assert.equal(readFileSync(lock, 'utf8'), '{"keep":true}\n'); + await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', () => resolve()); + }); + assert.equal(processIdentity(child.pid), null); + const backedUp = output.match(/Backed up the manifest to (\S+)\./); + assert.ok(backedUp); + assert.equal(readFileSync(backedUp[1], 'utf8').includes('/tmp/other-checkout'), true); + assert.equal(readFileSync(manifestPath, 'utf8').includes('/tmp/other-checkout'), true); } finally { - console.warn = origWarn; + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + writeFileSync(manifestPath, original); + writeFileSync(lock, before); } - assert.equal(readFileSync(lock, 'utf8'), '{"tool":true}\n'); - assert.equal(warnings.some((line) => line.includes('Leaving files in place')), true); - writeFileSync(manifestPath, original); - writeFileSync(lock, before); - assertSnapshotCheckout(); }); test('a hand edit survives and a tool write is restored', () => { diff --git a/AGENTS.md b/AGENTS.md index e65ccdf4..b3836227 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Launch and cleanup warn and leave files in place when the snapshot names a different checkout. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. +`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Launch refuses when the snapshot names a different checkout, has no checkout identity, or cannot be read, and tells you to remove `/tmp/synac-verify/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout, backs that manifest up, and skips the file restore. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes From 78d3bddceadcb23ee811d078843044b4a5da5753 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 14:01:41 +0000 Subject: [PATCH 17/23] fix: signal the verify SIGKILL fixture only after it ignores SIGTERM The bash trap was not installed before SIGTERM on a fast runner, so cleanup reported a graceful stop. Wait until the fixture writes a ready file, and stop polling if it never does. Co-authored-by: Aman Thanvi --- .../verify-synac/scripts/runtime.test.mjs | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index 90fbe443..c55d1cdf 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -378,13 +378,50 @@ test('cleanup does not stop a run recorded for another checkout', () => { }); test('SIGKILL waits for the launch start time to still match', async () => { - const child = spawn('bash', ['-c', 'trap "" TERM; while true; do sleep 30; done'], { - stdio: 'ignore', + mkdirSync(state, { recursive: true }); + const ready = path.join(state, 'sigkill-ready'); + rmSync(ready, { force: true }); + const child = spawn( + process.execPath, + [ + '-e', + 'const fs = require("node:fs"); process.on("SIGTERM", () => {}); fs.writeFileSync(process.env.SYNAC_SIGKILL_READY, "ok"); setInterval(() => {}, 1e9);', + ], + { + env: { ...process.env, SYNAC_SIGKILL_READY: ready }, + stdio: ['ignore', 'ignore', 'pipe'], + }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; }); try { await new Promise((resolve, reject) => { - child.once('spawn', resolve); - child.once('error', reject); + let settled = false; + let timer; + const finish = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(); + }; + timer = setTimeout(() => finish(new Error(`signal fixture did not become ready\n${stderr}`)), 5000); + const wait = () => { + if (settled) return; + if (exists(ready)) { + finish(); + return; + } + setTimeout(wait, 20); + }; + child.once('error', (error) => finish(error)); + child.once('exit', (code, signal) => { + finish(new Error(`signal fixture exited early code=${code} signal=${signal}\n${stderr}`)); + }); + wait(); }); const saved = processIdentity(child.pid); assert.ok(saved); @@ -393,12 +430,17 @@ test('SIGKILL waits for the launch start time to still match', async () => { assert.equal(stopRecordedPid(wrong, 200), 'reused'); assert.equal(processIdentity(child.pid)?.pid, child.pid); assert.equal(stopRecordedPid(saved, 400), 'kill'); - const ended = await new Promise((resolve) => { + const ended = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('signal fixture survived SIGKILL')), 2000); + const done = (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }; if (child.exitCode !== null || child.signalCode !== null) { - resolve({ code: child.exitCode, signal: child.signalCode }); + done(child.exitCode, child.signalCode); return; } - child.once('exit', (code, signal) => resolve({ code, signal })); + child.once('exit', done); }); assert.equal(ended.signal, 'SIGKILL'); } finally { From ccb9fb7f69c79c1aa1f66e91c7f9bc8f39a98537 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 14:29:07 +0000 Subject: [PATCH 18/23] fix: isolate verify state by checkout and classify the last ingest error A second worktree was stopping the first checkout because both used /tmp/synac-verify and the same tmux session names. Key the directory and session names by the repo root hash, and signal only that checkout's pids. Indented stack frames no longer turn a later parser error into a network miss. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 32 ++--- .../skills/verify-synac/playwright.config.mjs | 3 +- .../references/features/content-check.md | 4 +- .../references/features/content-sync.md | 2 +- .../references/features/ingest.md | 4 +- .../skills/verify-synac/scripts/cleanup.mjs | 16 ++- .agents/skills/verify-synac/scripts/cli.mjs | 41 +++--- .../skills/verify-synac/scripts/cli.test.mjs | 32 +++++ .../skills/verify-synac/scripts/launch.mjs | 5 +- .../skills/verify-synac/scripts/runtime.mjs | 33 ++++- .../verify-synac/scripts/runtime.test.mjs | 118 ++++++++++++++++++ .github/workflows/verify-smoke.yml | 6 +- AGENTS.md | 2 +- 13 files changed, 245 insertions(+), 53 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index a2f44090..c327e4ea 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -39,30 +39,30 @@ pnpm verify:launch `pnpm verify:launch` is `node .agents/skills/verify-synac/scripts/launch.mjs`. It does the following: 1. Exit 2 when Node is older than 24. -2. If the saved snapshot names a different repo root, has no checkout identity, or cannot be read, exit and do not restore. The message names `/tmp/synac-verify/snapshot` and tells you to remove that directory or move it aside, then launch again. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. +2. If the saved snapshot names a different repo root, has no checkout identity, or cannot be read, exit and do not restore. The message names `/tmp/synac-verify-/snapshot` and tells you to remove that directory or move it aside, then launch again. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. 3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. Right after `npx convex dev` and `npx convex env set`, record only the Convex-owned paths that changed during that step (`skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`). Right after `next build` or the Next server start, record only `apps/web/AGENTS.md` and `apps/web/next-env.d.ts` when those changed during that step. Any other change is warned and left in place, including `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json`. Cleanup records generator-owned files that change after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. 4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. 6. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. -7. Start tmux session `synac-verify-convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Stdin is `/dev/null`, so Convex does not run its interactive agent-file install. The session name is written to `run.json` as soon as tmux returns, before the ready check. A launch that times out still leaves the session for cleanup. Ready means the log line `Convex functions ready` in `/tmp/synac-verify/synac-verify-convex.log`. The listener pid is stored with its `/proc` start time once port 3210 is open. +7. Start tmux session `synac-v--convex` with `CONVEX_AGENT_MODE=anonymous npx convex dev` when port 3210 is closed. Stdin is `/dev/null`, so Convex does not run its interactive agent-file install. The session name is written to `run.json` as soon as tmux returns, before the ready check. A launch that times out still leaves the session for cleanup. Ready means the log line `Convex functions ready` in `/tmp/synac-verify-/synac-verify-convex.log`. The listener pid is stored with its `/proc` start time once port 3210 is open. 8. Run `npx convex env set SYNAC_CONVEX_SERVICE_KEY local-dev`. -9. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. +9. Run `pnpm --filter @synac/content-tools sync`. Ready means stdout contains `tag convergence: verified`. The log is copied to `/tmp/synac-verify-/evidence/cli-sync.log`. That sync writes the compiled corpus into the local Convex deployment. A later search that returns `access control` is the read-back of those records. 10. Build the web app with `pnpm --filter @synac/web build` unless `VERIFY_WEB_MODE=dev` or `VERIFY_SKIP_BUILD=1` and `apps/web/.next/BUILD_ID` already exists. The build and the server both receive `NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210`, `NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000`, `SYNAC_CONVEX_SERVICE_KEY=local-dev`, and `SYNAC_RATE_LIMIT_SALT=local-dev`. -11. Start tmux session `synac-verify-web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The session is written to `run.json` before the port is open. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. +11. Start tmux session `synac-v--web` with `pnpm --filter @synac/web exec next start -H 127.0.0.1 -p 3000`. The session is written to `run.json` before the port is open. The `exec` form is required. `pnpm --filter @synac/web start -- -H 127.0.0.1` makes Next.js treat `-H` as a project directory. `VERIFY_WEB_MODE=dev` uses `pnpm --filter @synac/web exec next dev -H 127.0.0.1 -p 3000` instead of a production build. Say which mode the proof used. Ready means doctor is `READY`. `docs/contributing/local-dev.md` is the same stack, split across terminals. Use the helper so the sessions are recorded for cleanup. -Logs and state: +Logs and state. `` is the first 12 hex characters of sha256 of this repo root. A second worktree gets its own directory and its own tmux session names. `SYNAC_VERIFY_STATE_DIR` overrides the directory. -- `/tmp/synac-verify` is created mode `0700` and must be owned by the user who ran launch. If that directory already exists with another mode or owner, launch exits and does not change its permissions. -- `/tmp/synac-verify/run.json` lists sessions and listener pids this launch started. Each pid keeps the process start time and command line from `/proc`. -- `/tmp/synac-verify/synac-verify-convex.log` -- `/tmp/synac-verify/synac-verify-web.log` -- `/tmp/synac-verify/evidence/` is created mode `0700` at launch and kept by cleanup. If that directory already exists with another mode, launch exits and does not change its permissions. +- `/tmp/synac-verify-` is created mode `0700` and must be owned by the user who ran launch. If that directory already exists with another mode or owner, launch exits and does not change its permissions. +- `/tmp/synac-verify-/run.json` lists sessions and listener pids this launch started. Each pid keeps the process start time and command line from `/proc`. +- `/tmp/synac-verify-/synac-verify-convex.log` +- `/tmp/synac-verify-/synac-verify-web.log` +- `/tmp/synac-verify-/evidence/` is created mode `0700` at launch and kept by cleanup. If that directory already exists with another mode, launch exits and does not change its permissions. `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch refuses when that path is different, when the manifest has no checkout identity, or when the manifest cannot be read, and the error tells you to remove `/tmp/synac-verify/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout. It backs the foreign or legacy manifest up under `/tmp/synac-verify/restored//manifest.json`, warns, and skips the file restore. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, and it keeps each file mode so a restore puts the executable bit back. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify-/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch refuses when that path is different, when the manifest has no checkout identity, or when the manifest cannot be read, and the error tells you to remove `/tmp/synac-verify-/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout. It backs the foreign or legacy manifest up under `/tmp/synac-verify-/restored//manifest.json`, warns, and skips the file restore. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, and it keeps each file mode so a restore puts the executable bit back. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify-/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -134,7 +134,7 @@ One UI feature, after doctor is `READY`: ```bash E2E_BASE_URL=http://127.0.0.1:3000 \ -VERIFY_EVIDENCE_DIR=/tmp/synac-verify/evidence \ +VERIFY_EVIDENCE_DIR=/tmp/synac-verify-/evidence \ NODE_PATH="$PWD/apps/e2e/node_modules" \ node apps/e2e/node_modules/@playwright/test/cli.js test \ --config .agents/skills/verify-synac/playwright.config.mjs \ @@ -176,7 +176,7 @@ Seeded slugs the corpus is known to contain (the same ones `apps/e2e/tests/helpe ## Evidence -Proof artifacts go to `/tmp/synac-verify/evidence/` (override with `VERIFY_EVIDENCE_DIR`). Cleanup never deletes that directory. +Proof artifacts go to `/tmp/synac-verify-/evidence/` (override with `VERIFY_EVIDENCE_DIR`). Cleanup never deletes that directory. - `cli-*.log` and `cli-report.json` from the CLI helper - `cli-sync.log` from launch, with the line `tag convergence: verified` @@ -195,12 +195,12 @@ Side effects worth reading: - `localStorage['synac-theme']` and `data-theme` after a click and again after reload - clipboard text after Copy link to this sense - `tools/content/dist/compiled.json` after `pnpm content:compile` -- the isolated ingest directory `/tmp/synac-verify/ingest-content/generated/rfc4949.json` when the upstream fetch runs +- the isolated ingest directory `/tmp/synac-verify-/ingest-content/generated/rfc4949.json` when the upstream fetch runs - response status and JSON for the public read API, including a 304 on `If-None-Match` The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the final thrown error and its cause. A final error is an unindented `Name: message` line or a `[Name: message]` line, plus its indented cause. That error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup @@ -208,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-verify-convex` and `synac-verify-web` when `run.json` lists them. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot is foreign, legacy, or unreadable, it warns, copies that manifest aside, skips the file restore, and still stops processes recorded for this checkout. A run state from another checkout is not used to stop processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-v--convex` and `synac-v--web` when this checkout's `run.json` lists them. `` is the first 12 hex characters of sha256 of this repo root. Another worktree has a different directory and different session names, so cleanup does not stop that worktree's sessions or pids. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot is foreign, legacy, or unreadable, it warns, copies that manifest aside, skips the file restore, and still stops processes recorded for this checkout. A run state from another checkout is not used to stop processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify-/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify-/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/playwright.config.mjs b/.agents/skills/verify-synac/playwright.config.mjs index a83753d5..2377b75a 100644 --- a/.agents/skills/verify-synac/playwright.config.mjs +++ b/.agents/skills/verify-synac/playwright.config.mjs @@ -1,8 +1,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { evidenceDir } from './scripts/runtime.mjs'; + const skillDir = path.dirname(fileURLToPath(import.meta.url)); -const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? '/tmp/synac-verify/evidence'; export default { testDir: path.join(skillDir, 'scripts'), diff --git a/.agents/skills/verify-synac/references/features/content-check.md b/.agents/skills/verify-synac/references/features/content-check.md index 45805d80..72df76b0 100644 --- a/.agents/skills/verify-synac/references/features/content-check.md +++ b/.agents/skills/verify-synac/references/features/content-check.md @@ -13,7 +13,7 @@ A maintainer runs it from the repo root after editing `content/`. Readers never ## Driving it with the CLI -`pnpm verify:smoke` runs this as the first CLI step. The log is `/tmp/synac-verify/evidence/cli-content-check.log`. +`pnpm verify:smoke` runs this as the first CLI step. The log is `/tmp/synac-verify-/evidence/cli-content-check.log`. ```bash pnpm content:check @@ -24,7 +24,7 @@ Stdout contains `content ok:` and `(version ` plus 12 hex characters. Exit 0. The hostile check uses a scratch directory so the repo stays untouched: ```bash -dir=/tmp/synac-verify/hostile-content +dir=/tmp/synac-verify-/hostile-content mkdir -p "$dir" printf '{' > "$dir/tags.json" SYNAC_CONTENT_DIR="$dir" pnpm content:check diff --git a/.agents/skills/verify-synac/references/features/content-sync.md b/.agents/skills/verify-synac/references/features/content-sync.md index 3ee8f25e..d7a74cc0 100644 --- a/.agents/skills/verify-synac/references/features/content-sync.md +++ b/.agents/skills/verify-synac/references/features/content-sync.md @@ -14,7 +14,7 @@ A maintainer runs sync after `npx convex dev` and `npx convex env set SYNAC_CONV ## Driving it with the CLI -`pnpm verify:launch` runs sync once and copies stdout to `/tmp/synac-verify/evidence/cli-sync.log`. +`pnpm verify:launch` runs sync once and copies stdout to `/tmp/synac-verify-/evidence/cli-sync.log`. `` is the first 12 hex characters of sha256 of the repo root. ```bash pnpm --filter @synac/content-tools sync diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index cd615605..80d883d2 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -36,7 +36,7 @@ Exit 1. Output contains `no source registry file`. Live fetch, isolated so the repo bundle cannot change: ```bash -dir=/tmp/synac-verify/ingest-content +dir=/tmp/synac-verify-/ingest-content mkdir -p "$dir/sources" "$dir/generated" cp content/sources/rfc4949.json "$dir/sources/" cp content/generated/rfc4949.json "$dir/generated/" @@ -45,7 +45,7 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the final thrown error and its cause. That error is the last unindented `Name: message` line or `[Name: message]` line. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas diff --git a/.agents/skills/verify-synac/scripts/cleanup.mjs b/.agents/skills/verify-synac/scripts/cleanup.mjs index cd5fc012..0dc09db4 100755 --- a/.agents/skills/verify-synac/scripts/cleanup.mjs +++ b/.agents/skills/verify-synac/scripts/cleanup.mjs @@ -7,6 +7,9 @@ import { readRun, restoreOwnedWrites, sessionAlive, + sessionConvex, + sessionWeb, + stateDir, stopRecordedPid, tmux, warnUnclaimedChanges, @@ -15,18 +18,19 @@ import { const help = `Usage: node .agents/skills/verify-synac/scripts/cleanup.mjs -Stops tmux sessions this skill started (synac-verify-convex and synac-verify-web) -and the listener pids recorded in /tmp/synac-verify/run.json, after checking -each pid's start time. Sends SIGTERM, then SIGKILL only while that start -time still matches. A foreign or legacy snapshot is backed up and not restored. Processes +Stops tmux sessions this skill started for this checkout (${sessionConvex} and +${sessionWeb}) and the listener pids recorded in ${stateDir}/run.json, after +checking each pid's start time. Sends SIGTERM, then SIGKILL only while that +start time still matches. Another checkout's sessions and pids are left +alone. A foreign or legacy snapshot is backed up and not restored. Processes recorded for this checkout are still stopped. Restores a watched file to the launch snapshot when its current bytes still match a recorded tool write. Backs that file up under -/tmp/synac-verify/restored// first, including when the path is a +${stateDir}/restored// first, including when the path is a symlink. Never changes the git index. Any other change is left in place and printed as a warning. -Leaves /tmp/synac-verify/evidence/ in place. +Leaves ${stateDir}/evidence/ in place. `; function main() { diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index 4e0261c8..c623e83a 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -14,8 +14,8 @@ Runs the content CLI and checks side effects. default smoke, plus content:history, a hostile content dir, ingest usage, and an isolated rfc4949 ingest. A network-unreachable ingest is INCONCLUSIVE when the final thrown error and its cause are a - network failure with no HTTP status. Any HTTP status, parser - error, or other failure exits 1. + network failure with no HTTP status. Indented stack frames are + ignored. Any HTTP status, parser error, or other failure exits 1. `; const compiledPath = path.join(root, 'tools/content/dist/compiled.json'); @@ -166,43 +166,48 @@ function ingestStep(report) { const networkUnreachable = /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b/; -function isNamedError(line) { - return /^(?:\[[A-Za-z_][\w$]*|[A-Za-z_][\w$]*): \S/.test(line); +function isWrapperLine(line) { + if (line.length === 0 || line === '^' || line.startsWith('\u2009')) return true; + if (/^\$\s/.test(line) || /^>\s/.test(line)) return true; + if (/^exit\s+\d+$/.test(line) || /^Exit status\b/.test(line)) return true; + if (/^(?:npm|pnpm)\s/.test(line)) return true; + if (/^\/\S+:$/.test(line)) return true; + return false; } -function isTopLevelError(line) { - if (/^\s/.test(line) || line.length === 0) return false; - if (isCauseLine(line)) return false; - return /^[✗✘]\s+\S+:\s+\S/.test(line) || isNamedError(line); +function isErrorLine(line) { + if (line.length === 0 || /^\s/.test(line)) return false; + return !isWrapperLine(line); } -function isCauseLine(line) { - return ( - /^\s+\S/.test(line) || - /^\[cause\]:/i.test(line) || - /^caused by:/i.test(line) || - /^cause:/i.test(line) - ); +function isExplicitCause(line) { + const trimmed = line.trim(); + return /^\[cause\]:/i.test(trimmed) || /^caused by:/i.test(trimmed) || /^cause:/i.test(trimmed); +} + +function isStackFrame(line) { + return /^\s+at\s/.test(line); } export function finalThrownError(text) { const lines = text.split(/\r?\n/); let start = -1; for (let i = 0; i < lines.length; i += 1) { - if (isTopLevelError(lines[i] ?? '')) start = i; + if (isErrorLine(lines[i] ?? '')) start = i; } if (start === -1) return ''; const block = [lines[start] ?? '']; for (let i = start + 1; i < lines.length; i += 1) { const line = lines[i] ?? ''; - if (isCauseLine(line)) { + if (isExplicitCause(line)) { block.push(line); continue; } - if (line.trim() === '' && isCauseLine(lines[i + 1] ?? '')) { + if (line.trim() === '' && isExplicitCause(lines[i + 1] ?? '')) { block.push(line); continue; } + if (isStackFrame(line)) continue; break; } return block.join('\n'); diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs index 12bab6c7..07545078 100644 --- a/.agents/skills/verify-synac/scripts/cli.test.mjs +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -76,6 +76,38 @@ test('a bracketed Error after a fetch-failed retry is FAIL', () => { assert.equal(ingestNetworkUnreachable(log), false); }); +test('a later RangeError, ZodError, or IngestError wins over TypeError: fetch failed', () => { + const earlier = [ + 'TypeError: fetch failed', + ' at node:internal/deps/undici/undici:14902:13', + ' [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + ]; + for (const finalLine of [ + 'RangeError: Invalid array length', + 'ZodError: invalid document', + 'IngestError: parser rejected the document', + "[Error: Unexpected token '<' is not valid JSON]", + "Unexpected token '<' is not valid JSON", + ]) { + const log = [...earlier, finalLine].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false, finalLine); + } +}); + +test('an indented ENOTFOUND frame does not make a parser error INCONCLUSIVE', () => { + const log = [ + 'TypeError: fetch failed', + ' at node:internal/deps/undici/undici:14902:13', + ' Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + "SyntaxError: Unexpected token '<'", + ' at JSON.parse ()', + ' at parse (ingest.js:10:5)', + ' Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + '\u2009ELIFECYCLE\u2009 Command failed with exit code 1.', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), false); +}); + test('cli.mjs --help runs through the .cursor skill symlink', () => { const result = spawnSync( process.execPath, diff --git a/.agents/skills/verify-synac/scripts/launch.mjs b/.agents/skills/verify-synac/scripts/launch.mjs index 8cdc3e70..94f986f8 100755 --- a/.agents/skills/verify-synac/scripts/launch.mjs +++ b/.agents/skills/verify-synac/scripts/launch.mjs @@ -34,8 +34,9 @@ import { const help = `Usage: node .agents/skills/verify-synac/scripts/launch.mjs -Starts the local SynAc stack in tmux sessions synac-verify-convex and -synac-verify-web, syncs repo content, and waits until doctor is READY. +Starts the local SynAc stack in tmux sessions ${sessionConvex} and +${sessionWeb}, syncs repo content, and waits until doctor is READY. +State for this checkout is ${stateDir}. Env: VERIFY_WEB_MODE=start|dev start is a production build (default) diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index a0892b44..315d694b 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -9,6 +9,7 @@ import { readdirSync, readFileSync, readlinkSync, + realpathSync, renameSync, rmdirSync, rmSync, @@ -25,6 +26,31 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); export const root = path.resolve(process.env.SYNAC_VERIFY_ROOT ?? path.resolve(scriptDir, '../../../..')); if (process.cwd() !== root) process.chdir(root); +function canonicalRoot(repoRoot) { + const resolved = path.resolve(repoRoot); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + +export function checkoutKey(repoRoot) { + return createHash('sha256').update(canonicalRoot(repoRoot)).digest('hex').slice(0, 12); +} + +export function stateDirFor(repoRoot) { + return path.join('/tmp', `synac-verify-${checkoutKey(repoRoot)}`); +} + +export function sessionsFor(repoRoot) { + const key = checkoutKey(repoRoot); + return { + convex: `synac-v-${key}-convex`, + web: `synac-v-${key}-web`, + }; +} + const namedWatched = [ 'AGENTS.md', 'CLAUDE.md', @@ -45,7 +71,7 @@ const handMaintained = new Set([ const snapshotDirName = 'snapshot'; let tookSnapshot = false; -export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? '/tmp/synac-verify'; +export const stateDir = process.env.SYNAC_VERIFY_STATE_DIR ?? stateDirFor(root); export const evidenceDir = process.env.VERIFY_EVIDENCE_DIR ?? path.join(stateDir, 'evidence'); export const runFile = path.join(stateDir, 'run.json'); @@ -54,8 +80,9 @@ export const webPort = 3000; export const convexPort = 3210; export const convexSitePort = 3211; export const convexUrl = 'http://127.0.0.1:3210'; -export const sessionConvex = 'synac-verify-convex'; -export const sessionWeb = 'synac-verify-web'; +const ownSessions = sessionsFor(root); +export const sessionConvex = ownSessions.convex; +export const sessionWeb = ownSessions.web; export const ownedSessions = new Set([sessionConvex, sessionWeb]); const tmuxConfig = process.env.SYNAC_VERIFY_TMUX_CONFIG diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index c55d1cdf..910331e5 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -65,6 +65,10 @@ const { readRun, stopRecordedPid, processIdentity, + sessionConvex, + sessionsFor, + stateDirFor, + tmux, } = await import('./runtime.mjs'); after(() => { @@ -452,6 +456,120 @@ test('SIGKILL waits for the launch start time to still match', async () => { } }); +function writeCheckoutRun(dir, repoRoot, sessions, identity) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + writeFileSync( + path.join(dir, 'run.json'), + `${JSON.stringify( + { + sessions, + pids: { convex: identity, web: null, convexSite: null }, + startedAt: '', + webMode: 'start', + baseUrl: 'http://127.0.0.1:3000', + repoRoot, + }, + null, + 2, + )}\n`, + ); +} + +test('cleanup from a second checkout does not stop the first', async () => { + const rootB = `/tmp/synac-verify-repo-b-${process.pid}`; + mkdirSync(rootB, { recursive: true }); + const dirA = stateDirFor(root); + const dirB = stateDirFor(rootB); + const sessionsA = sessionsFor(root); + const sessionsB = sessionsFor(rootB); + assert.notEqual(dirA, dirB); + assert.match(dirA, /^\/tmp\/synac-verify-[0-9a-f]{12}$/); + assert.notEqual(sessionsA.convex, sessionsB.convex); + assert.equal(sessionConvex, sessionsA.convex); + assert.notEqual(sessionConvex, 'synac-verify-convex'); + const started = []; + const sleepA = spawn('sleep', ['60'], { stdio: 'ignore' }); + let sleepB; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep A did not spawn')), 2000); + sleepA.once('spawn', () => { + clearTimeout(timer); + resolve(); + }); + sleepA.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); + sleepB = spawn('sleep', ['60'], { stdio: 'ignore' }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep B did not spawn')), 2000); + sleepB.once('spawn', () => { + clearTimeout(timer); + resolve(); + }); + sleepB.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); + const idA = processIdentity(sleepA.pid); + const idB = processIdentity(sleepB.pid); + assert.ok(idA); + assert.ok(idB); + writeCheckoutRun(dirA, root, [sessionsA.convex], idA); + writeCheckoutRun(dirB, rootB, [sessionsB.convex, sessionsA.convex], idB); + for (const name of [sessionsA.convex, sessionsB.convex]) { + tmux(['kill-session', '-t', name], { timeout: 5000 }); + const created = tmux(['new-session', '-d', '-s', name, 'sleep', '60'], { timeout: 5000 }); + assert.equal(created.status, 0, created.stderr); + started.push(name); + } + const env = { ...process.env }; + delete env.SYNAC_VERIFY_STATE_DIR; + delete env.VERIFY_EVIDENCE_DIR; + env.SYNAC_VERIFY_ROOT = rootB; + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { env, encoding: 'utf8', timeout: 20_000 }); + assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, new RegExp(`stopped session ${sessionsB.convex}`)); + assert.doesNotMatch(output, new RegExp(`stopped session ${sessionsA.convex}`)); + assert.doesNotMatch(output, new RegExp(`\\b${sleepA.pid}\\b`)); + assert.equal(tmux(['has-session', '-t', sessionsA.convex]).status, 0); + assert.notEqual(tmux(['has-session', '-t', sessionsB.convex]).status, 0); + assert.equal(processIdentity(sleepA.pid)?.pid, sleepA.pid); + assert.match(output, new RegExp(`\\b${sleepB.pid}\\b`)); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep B was not stopped')), 3000); + const done = () => { + clearTimeout(timer); + resolve(); + }; + if (sleepB.exitCode !== null || sleepB.signalCode !== null) done(); + else sleepB.once('exit', done); + }); + assert.equal(processIdentity(sleepB.pid), null); + } finally { + for (const name of started) tmux(['kill-session', '-t', name], { timeout: 5000 }); + try { + sleepA.kill('SIGKILL'); + } catch { + // already exited + } + try { + sleepB?.kill('SIGKILL'); + } catch { + // already exited + } + rmSync(rootB, { recursive: true, force: true }); + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } +}); + test('runtime checks use the temp repo and leave the real checkout alone', () => { rmSync(scratchDir, { recursive: true, force: true }); assert.equal(root, repo); diff --git a/.github/workflows/verify-smoke.yml b/.github/workflows/verify-smoke.yml index 51ce6c14..90805bc6 100644 --- a/.github/workflows/verify-smoke.yml +++ b/.github/workflows/verify-smoke.yml @@ -59,11 +59,15 @@ jobs: if: always() run: pnpm verify:cleanup + - name: Resolve evidence dir + if: always() + run: node --input-type=module -e 'import { evidenceDir } from "./.agents/skills/verify-synac/scripts/runtime.mjs"; import { appendFileSync } from "node:fs"; appendFileSync(process.env.GITHUB_ENV, `VERIFY_EVIDENCE_DIR=${evidenceDir}\n`);' + - name: Upload evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 with: name: verify-smoke-evidence - path: /tmp/synac-verify/evidence + path: ${{ env.VERIFY_EVIDENCE_DIR }} if-no-files-found: ignore retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index b3836227..b2b646fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. Launch refuses when the snapshot names a different checkout, has no checkout identity, or cannot be read, and tells you to remove `/tmp/synac-verify/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout, backs that manifest up, and skips the file restore. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. +`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. State for each checkout lives in `/tmp/synac-verify-`, where `` is the first 12 hex characters of sha256 of that repo root. Launch and cleanup use tmux sessions `synac-v--convex` and `synac-v--web`, and they signal only the pids recorded in that directory. Cleanup from a second worktree does not stop the first checkout. Launch refuses when the snapshot names a different checkout, has no checkout identity, or cannot be read, and tells you to remove that checkout's snapshot directory or move it aside. Cleanup still stops processes recorded for this checkout, backs that manifest up, and skips the file restore. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify-/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes From 2db8aa94009049ecabfc5263d510bcbbcb58e79f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 14:38:46 +0000 Subject: [PATCH 19/23] fix: restore an unfinished verify launch and ignore unnamed warnings A warning: line is no longer an ingest error unless the name ends in Error or Exception. A run.json with no repo root is backed up and its pids are left alone. Each tool claim is flushed to writes.json immediately, and the next launch restores tool-owned files from a snapshot that never finished. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 8 +- .../references/features/ingest.md | 2 +- .agents/skills/verify-synac/scripts/cli.mjs | 16 ++- .../skills/verify-synac/scripts/cli.test.mjs | 14 +++ .../skills/verify-synac/scripts/runtime.mjs | 97 +++++++++++++++++-- .../verify-synac/scripts/runtime.test.mjs | 80 +++++++++++++++ AGENTS.md | 2 +- 7 files changed, 205 insertions(+), 14 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index c327e4ea..15db9ed8 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -40,7 +40,7 @@ pnpm verify:launch 1. Exit 2 when Node is older than 24. 2. If the saved snapshot names a different repo root, has no checkout identity, or cannot be read, exit and do not restore. The message names `/tmp/synac-verify-/snapshot` and tells you to remove that directory or move it aside, then launch again. If doctor is `READY`, print `REUSED` and exit 0. A live stack is left as it is: launch does not restore files and does not take a new snapshot. -3. Otherwise restore tool-owned files from the previous snapshot when their current bytes still match what that run wrote, then snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. Right after `npx convex dev` and `npx convex env set`, record only the Convex-owned paths that changed during that step (`skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`). Right after `next build` or the Next server start, record only `apps/web/AGENTS.md` and `apps/web/next-env.d.ts` when those changed during that step. Any other change is warned and left in place, including `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json`. Cleanup records generator-owned files that change after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. +3. Otherwise, if the previous launch was killed before it wrote its end-of-launch observation, restore every tool-owned file that differs from that snapshot. Then restore tool-owned files from a finished snapshot when their current bytes still match what that run wrote, and snapshot the hash, contents, mode, staged state, and repo root of the watched files listed below. Right after `npx convex dev` and `npx convex env set`, record only the Convex-owned paths that changed during that step (`skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`) and write each claim to `writes.json` as soon as it is recorded. Right after `next build` or the Next server start, record only `apps/web/AGENTS.md` and `apps/web/next-env.d.ts` when those changed during that step, the same way. Any other change is warned and left in place, including `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json`. Cleanup records generator-owned files that change after launch returns, and it warns about a new Convex skill file. Leave those bytes in the worktree until cleanup or the next launch that does not reuse a live stack. 4. If port 3000 or 3210 is open and the matching tmux session is not one this skill started, exit 2 and leave that process alone. 5. Run `pnpm install --frozen-lockfile` when `node_modules` is missing. 6. Install Playwright Chromium with `pnpm --filter @synac/e2e exec playwright install chromium`. Set `VERIFY_SKIP_BROWSER_INSTALL=1` to skip. If Chromium's system libraries are missing, run `pnpm --filter @synac/e2e exec playwright install --with-deps chromium` once. That step needs root. @@ -62,7 +62,7 @@ Logs and state. `` is the first 12 hex characters of sha256 of this repo r `.env.local` is written by `npx convex dev`. It is gitignored. Do not commit it. -Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify-/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch refuses when that path is different, when the manifest has no checkout identity, or when the manifest cannot be read, and the error tells you to remove `/tmp/synac-verify-/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout. It backs the foreign or legacy manifest up under `/tmp/synac-verify-/restored//manifest.json`, warns, and skips the file restore. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, and it keeps each file mode so a restore puts the executable bit back. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify-/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. +Launch snapshots the hash, contents, and staged state of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, `.agents/skills/convex*`, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. The copy is stored under `/tmp/synac-verify-/snapshot/`. `npx convex dev` can rewrite the Convex skill files, `convex/_generated/ai/`, `skills-lock.json`, and `.cursor/rules/convex_rules.mdc`, and it can add new skill directories. `next dev` can rewrite `apps/web/AGENTS.md` and `apps/web/next-env.d.ts`. Launch redirects Convex stdin from `/dev/null` so the interactive installer does not run. The snapshot stores this checkout's repo root. Launch refuses when that path is different, when the manifest has no checkout identity, or when the manifest cannot be read, and the error tells you to remove `/tmp/synac-verify-/snapshot` or move it aside. Cleanup still stops processes recorded for this checkout. It backs the foreign or legacy manifest up under `/tmp/synac-verify-/restored//manifest.json`, warns, and skips the file restore. A run state whose repo root is different is not used to stop processes. Launch records a write only from the hash taken immediately after the Convex or Next step that owns that path, writes that claim to `writes.json` before the next path, and keeps each file mode so a restore puts the executable bit back. If launch is killed before it writes the end-of-launch observation, the next launch that is not reusing a live stack restores tool-owned files from that snapshot before it takes a new one. Cleanup records generator-owned changes that appear after that observation and warns when a new file shows up under `.agents/skills/convex*`. A launch that prints `REUSED` does not restore. Cleanup and a later launch that is not reusing a live stack restore a tool-owned file, including `.cursor/rules/convex_rules.mdc` and `apps/web/next-env.d.ts`, only when its current bytes still equal that record. They write the launch snapshot back, or delete the file when it was absent. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. Each launch snapshot has its own run id. Before a restore they copy the current path to `/tmp/synac-verify-/restored//` and list that backup. A symlink is backed up as a symlink. They never change the git index. If the bytes or the staged state changed in any other way, the file stays and the command prints a warning. After launch the worktree may show those tool writes. After cleanup it matches the launch snapshot for every file that still held the run's bytes. ## Doctor @@ -200,7 +200,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A name counts only when it ends in `Error` or `Exception` (`TypeError`, `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`), or the line is an `✗` or `✘` result, or it is an unprefixed message. A `warning:` line is not an error. A later matching error wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup @@ -208,7 +208,7 @@ Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copi pnpm verify:cleanup ``` -That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-v--convex` and `synac-v--web` when this checkout's `run.json` lists them. `` is the first 12 hex characters of sha256 of this repo root. Another worktree has a different directory and different session names, so cleanup does not stop that worktree's sessions or pids. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot is foreign, legacy, or unreadable, it warns, copies that manifest aside, skips the file restore, and still stops processes recorded for this checkout. A run state from another checkout is not used to stop processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify-/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify-/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. +That runs `node .agents/skills/verify-synac/scripts/cleanup.mjs`. It `tmux kill-session`s only `synac-v--convex` and `synac-v--web` when this checkout's `run.json` lists them. `` is the first 12 hex characters of sha256 of this repo root. Another worktree has a different directory and different session names, so cleanup does not stop that worktree's sessions or pids. A `run.json` with no repo root is not this checkout. Cleanup warns, copies that file aside, and does not signal its pids. A session is listed as soon as launch starts it, so cleanup still stops a Convex or web process whose ready check never passed. Before it signals a saved pid, it reads `/proc/` and checks the start time recorded at launch (the command line when the start time was not stored). It sends SIGTERM, then SIGKILL only if that same start time is still the process. A reused pid is skipped and named in the log, including when the pid is reused during the wait before SIGKILL. The same pid is signaled once when two ports share it. It does not kill processes by name. When the snapshot is foreign, legacy, or unreadable, it warns, copies that manifest aside, skips the file restore, and still stops processes recorded for this checkout. A run state from another checkout is not used to stop processes. It records generator-owned writes that appeared after launch returned, warns about a new Convex skill file, and restores a tool-owned file, including `.cursor/rules/convex_rules.mdc`, to the launch snapshot only when the current bytes still match that record. A missing backup id gets its own directory instead of a shared `unknown` directory. The restored file keeps the mode from the snapshot. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. It copies the current path, symlink or file, to `/tmp/synac-verify-/restored//` and lists each backup before writing the snapshot back. It never changes the git index. Any other change is left in place and printed as a warning. It leaves `/tmp/synac-verify-/evidence/` in place and prints that path. After every failed launch or drive, run cleanup before starting again so a half-started session does not keep the ports. Confirm the evidence directory still exists after cleanup before citing it. diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index 80d883d2..a48bb518 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,7 +45,7 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. The name must end in `Error` or `Exception`. A `warning:` line is not an error. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index c623e83a..3ab74771 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -175,9 +175,21 @@ function isWrapperLine(line) { return false; } +function isNamedError(line) { + const match = /^(?:\[([A-Za-z_][\w$]*)|([A-Za-z_][\w$]*)): \S/.exec(line); + if (!match) return false; + const name = match[1] ?? match[2] ?? ''; + return name.endsWith('Error') || name.endsWith('Exception'); +} + +function isMarkedError(line) { + return /^[✗✘]\s+\S/.test(line); +} + function isErrorLine(line) { - if (line.length === 0 || /^\s/.test(line)) return false; - return !isWrapperLine(line); + if (line.length === 0 || /^\s/.test(line) || isWrapperLine(line)) return false; + if (isMarkedError(line) || isNamedError(line)) return true; + return !/: /.test(line); } function isExplicitCause(line) { diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs index 07545078..da2719ec 100644 --- a/.agents/skills/verify-synac/scripts/cli.test.mjs +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -94,6 +94,20 @@ test('a later RangeError, ZodError, or IngestError wins over TypeError: fetch fa } }); +test('a warning line is not an error name', () => { + const stolen = [ + "SyntaxError: Unexpected token '<'", + 'warning: getaddrinfo ENOTFOUND www.rfc-editor.org', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(stolen), false); + const kept = [ + 'TypeError: fetch failed', + ' [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.org', + 'warning: leaving files in place', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(kept), true); +}); + test('an indented ENOTFOUND frame does not make a parser error INCONCLUSIVE', () => { const log = [ 'TypeError: fetch failed', diff --git a/.agents/skills/verify-synac/scripts/runtime.mjs b/.agents/skills/verify-synac/scripts/runtime.mjs index 315d694b..a0f53089 100755 --- a/.agents/skills/verify-synac/scripts/runtime.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.mjs @@ -125,10 +125,37 @@ export function ensureState() { preparePrivateDir(evidenceDir); } +function readRunFile() { + if (!existsSync(runFile)) return { status: 'missing' }; + let raw; + try { + raw = JSON.parse(readFileSync(runFile, 'utf8')); + } catch { + return { status: 'unreadable' }; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { status: 'unreadable' }; + const repoRoot = typeof raw.repoRoot === 'string' ? raw.repoRoot : ''; + if (!repoRoot) return { status: 'legacy' }; + if (repoRoot !== root) return { status: 'foreign', repoRoot }; + return { status: 'ok' }; +} + +function backupRunFile() { + const id = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const dir = path.join(stateDir, 'restored', id); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const dest = path.join(dir, 'run.json'); + copyFileSync(runFile, dest); + return dest; +} + export function readRun() { + const identity = readRunFile(); + if (identity.status === 'legacy' || identity.status === 'unreadable') return emptyRun(); try { const value = JSON.parse(readFileSync(runFile, 'utf8')); - if (!value || typeof value !== 'object') return emptyRun(); + if (!value || typeof value !== 'object' || Array.isArray(value)) return emptyRun(); const pids = value.pids && typeof value.pids === 'object' ? value.pids : {}; return { sessions: Array.isArray(value.sessions) ? value.sessions : [], @@ -173,6 +200,13 @@ function normalizePid(value) { export function writeRun(run) { ensureState(); + const identity = readRunFile(); + if (identity.status === 'legacy' || identity.status === 'unreadable') { + const dest = backupRunFile(); + console.warn( + `warning: run state has no checkout identity. Backed up the run state to ${dest}.`, + ); + } const next = { ...run, repoRoot: typeof run.repoRoot === 'string' && run.repoRoot ? run.repoRoot : root }; writeFileSync(runFile, `${JSON.stringify(next, null, 2)}\n`); } @@ -773,14 +807,19 @@ export function beginCleanup() { } export function runBelongsToCheckout() { - const run = readRun(); - if (run.repoRoot && run.repoRoot !== root) { + const identity = readRunFile(); + if (identity.status === 'missing' || identity.status === 'ok') return true; + if (identity.status === 'foreign') { console.warn( - `warning: run state is for ${run.repoRoot}, this checkout is ${root}. Not stopping those processes.`, + `warning: run state is for ${identity.repoRoot}, this checkout is ${root}. Not stopping those processes.`, ); return false; } - return true; + const dest = backupRunFile(); + const why = + identity.status === 'legacy' ? 'run state has no checkout identity' : 'run state is unreadable'; + console.warn(`warning: ${why}. Not stopping those processes. Backed up the run state to ${dest}.`); + return false; } function isNewConvexSkill(rel, snap) { @@ -853,12 +892,13 @@ export function captureToolWrites(tool) { if (!current || sameDescribed(current, priorStep)) continue; if (sameDescribed(current, snapDescribed(files, rel))) { delete writeMap[rel]; + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); continue; } if (isNewConvexSkill(rel, files[rel])) console.warn(`warning: new convex skill file ${rel}`); writeMap[rel] = writeRecord(current, tool); + writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); } - writeFileSync(writesPath(), `${JSON.stringify(writeMap, null, 2)}\n`); stepBaseline = described; } @@ -934,8 +974,53 @@ export function warnUnclaimedChanges() { } } +function priorRunUnfinished() { + if (existsSync(observedPath())) return false; + return loadManifest().status === 'ok'; +} + +export function restoreUnfinishedRun() { + if (!priorRunUnfinished()) return { restored: [] }; + console.warn('warning: previous launch did not finish. Restoring tool-owned files from its snapshot.'); + try { + return restoreUnfinishedInner(); + } finally { + endRestoreBackup(); + } +} + +function restoreUnfinishedInner() { + const manifest = readManifest(); + if (!manifest) return { restored: [] }; + const restored = []; + for (const rel of watchedRels()) { + if (!safeRel(rel) || handMaintained.has(rel)) continue; + if (!toolOwns('convex', rel) && !toolOwns('next', rel)) continue; + const snap = manifest.files[rel] ?? absentRecord(); + const current = describeWorktree(rel); + if (sameDescribed(current, { kind: snap.kind, hash: snap.hash ?? null })) continue; + const liveStage = stagedRecord(rel); + const snapIndex = typeof snap.indexHash === 'string' ? snap.indexHash : null; + const snapStaged = snap.staged === true; + if (liveStage.indexHash !== snapIndex || liveStage.staged !== snapStaged) { + console.warn(`warning: ${rel} staged state changed during the run and was left in place`); + continue; + } + const dest = backupRel(rel); + if (dest) console.log(`backed up ${rel} to ${dest}`); + if (!placeSnapshot(rel, snap)) { + console.warn(`warning: ${rel} changed during the run and was left in place`); + continue; + } + restored.push(rel); + console.log(`restored ${rel} to launch snapshot`); + } + return { restored }; +} + export function beginLaunch(doctorStatus) { if (doctorStatus === 'READY') return 'reuse'; + restoreUnfinishedRun(); restoreOwnedWrites(); snapshotWatchedFiles(); return 'snapshot'; diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index 910331e5..7d9b0964 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -63,6 +63,7 @@ const { runBelongsToCheckout, writeRun, readRun, + runFile, stopRecordedPid, processIdentity, sessionConvex, @@ -357,6 +358,27 @@ test('an existing evidence directory with the wrong mode is refused', () => { chmodSync(evidence, 0o700); }); +test('an unfinished launch restores tool writes before the next snapshot', () => { + const lock = path.join(root, 'skills-lock.json'); + const original = readFileSync(lock, 'utf8'); + const agents = path.join(root, 'AGENTS.md'); + const agentsOriginal = readFileSync(agents, 'utf8'); + try { + snapshotWatchedFiles(); + assert.equal(readFileSync(path.join(state, 'snapshot/writes.json'), 'utf8').trim(), '{}'); + assert.equal(exists(path.join(state, 'snapshot/observed.json')), false); + writeFileSync(lock, '{"killed":true}\n'); + writeFileSync(agents, `${agentsOriginal}human\n`); + assert.equal(beginLaunch('NOT_READY'), 'snapshot'); + assert.equal(readFileSync(lock, 'utf8'), original); + assert.equal(readFileSync(agents, 'utf8'), `${agentsOriginal}human\n`); + assert.equal(readFileSync(path.join(state, 'snapshot/files/skills-lock.json'), 'utf8'), original); + } finally { + writeFileSync(lock, original); + writeFileSync(agents, agentsOriginal); + } +}); + test('cleanup does not stop a run recorded for another checkout', () => { const warnings = []; const orig = console.warn; @@ -381,6 +403,64 @@ test('cleanup does not stop a run recorded for another checkout', () => { }); }); +test('a run.json with no repoRoot is not this checkout', async () => { + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep did not spawn')), 2000); + child.once('spawn', () => { + clearTimeout(timer); + resolve(); + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); + const saved = processIdentity(child.pid); + assert.ok(saved); + ensureState(); + writeFileSync( + runFile, + `${JSON.stringify({ sessions: [], pids: { convex: saved, web: null, convexSite: null } })}\n`, + ); + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { + env: { + ...process.env, + SYNAC_VERIFY_ROOT: repo, + SYNAC_VERIFY_STATE_DIR: state, + VERIFY_EVIDENCE_DIR: path.join(state, 'evidence'), + }, + encoding: 'utf8', + timeout: 20_000, + }); + assert.equal(result.status, 0, result.stderr); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, /no checkout identity/); + assert.match(output, /Not stopping those processes/); + assert.match(output, /Backed up the run state to /); + assert.doesNotMatch(output, new RegExp(`\\b${child.pid}\\b`)); + assert.equal(processIdentity(child.pid)?.pid, child.pid); + const backedUp = output.match(/Backed up the run state to (\S+)\./); + assert.ok(backedUp); + assert.equal(readFileSync(backedUp[1], 'utf8').includes('repoRoot'), false); + assert.equal(readFileSync(runFile, 'utf8').includes('repoRoot'), false); + } finally { + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + writeRun({ + ...readRun(), + repoRoot: root, + sessions: [], + pids: { convex: null, web: null, convexSite: null }, + }); + } +}); + test('SIGKILL waits for the launch start time to still match', async () => { mkdirSync(state, { recursive: true }); const ready = path.join(state, 'sigkill-ready'); diff --git a/AGENTS.md b/AGENTS.md index b2b646fe..595a424d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Source of truth: `docs/architecture/overview.md` (architecture) + `SPEC.md` (pro Drive the local stack from the repo root with `pnpm verify:launch`, `pnpm verify:doctor`, `pnpm verify:smoke`, `pnpm verify:drive`, and `pnpm verify:cleanup`. From a package directory, use the workspace form (`pnpm -w verify:doctor`, and the same `-w` prefix for the other verify scripts). The scripts `chdir` to the repo root themselves. The procedure and the fast test groups live in `.agents/skills/verify-synac/SKILL.md`. Feature work includes a Proof section: commands, evidence paths that survive cleanup, results, and `INCONCLUSIVE` when a step could not run. While iterating, run the named fast group for the package you touched. Run `pnpm gate` once on the final head. -`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. State for each checkout lives in `/tmp/synac-verify-`, where `` is the first 12 hex characters of sha256 of that repo root. Launch and cleanup use tmux sessions `synac-v--convex` and `synac-v--web`, and they signal only the pids recorded in that directory. Cleanup from a second worktree does not stop the first checkout. Launch refuses when the snapshot names a different checkout, has no checkout identity, or cannot be read, and tells you to remove that checkout's snapshot directory or move it aside. Cleanup still stops processes recorded for this checkout, backs that manifest up, and skips the file restore. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify-/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. +`pnpm verify:launch` snapshots the hash, contents, mode, staged state, and repo root of `AGENTS.md`, `CLAUDE.md`, `apps/web/AGENTS.md`, `apps/web/CLAUDE.md`, `convex.json`, `skills-lock.json`, Convex skill files, `convex/_generated/ai/`, `.cursor/rules/convex_rules.mdc`, and `apps/web/next-env.d.ts`. State for each checkout lives in `/tmp/synac-verify-`, where `` is the first 12 hex characters of sha256 of that repo root. Launch and cleanup use tmux sessions `synac-v--convex` and `synac-v--web`, and they signal only the pids recorded in that directory. Cleanup from a second worktree does not stop the first checkout. A `run.json` with no repo root is backed up and its pids are not signaled. If launch is killed before it finishes, the next launch restores tool-owned files from that snapshot before it takes a new one. Each tool claim is written to `writes.json` as soon as it is recorded. Launch refuses when the snapshot names a different checkout, has no checkout identity, or cannot be read, and tells you to remove that checkout's snapshot directory or move it aside. Cleanup still stops processes recorded for this checkout, backs that manifest up, and skips the file restore. A live stack that prints `REUSED` is not restored. A write is recorded only from the hash taken immediately after the Convex or Next step that owns that path. Cleanup and a later launch restore those tool-owned files, including `.cursor/rules/convex_rules.mdc` and Convex skill files, to that snapshot only when the bytes still match what the run wrote. `AGENTS.md`, `CLAUDE.md`, `apps/web/CLAUDE.md`, and `convex.json` are left in place and warned. They back the current path up under `/tmp/synac-verify-/restored//` first, including when the path is a symlink, and never change the git index. Any other change is left in place and printed as a warning. ## Cloud Agent notes From 337ce80ccaef80580c22165c3096e56bd299c1c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 15:14:11 +0000 Subject: [PATCH 20/23] fix: keep a fetch error dump and the ingest CLI line inconclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Node console.error of a fetch TypeError ends on a bare brace, which was read as the final error and hid ENOTFOUND. Lines that are only braces or punctuation are skipped. The ingest CLI prints error.message, so a line of the form "✗ : fetch failed" with no HTTP status is inconclusive. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- .../references/features/ingest.md | 2 +- .agents/skills/verify-synac/scripts/cli.mjs | 12 +++++-- .../skills/verify-synac/scripts/cli.test.mjs | 35 +++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 15db9ed8..0c1b75c5 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -200,7 +200,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A name counts only when it ends in `Error` or `Exception` (`TypeError`, `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`), or the line is an `✗` or `✘` result, or it is an unprefixed message. A `warning:` line is not an error. A later matching error wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, or `TypeError: fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A name counts only when it ends in `Error` or `Exception` (`TypeError`, `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`), or the line is an `✗` or `✘` result, or it is an unprefixed message. A `warning:` line is not an error. A line that is only braces or punctuation, including the closing `}` of a Node error dump, is not an error. A later matching error wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, `TypeError: fetch failed`, or the ingest CLI line `✗ : fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index a48bb518..33a29ea5 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,7 +45,7 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. The name must end in `Error` or `Exception`. A `warning:` line is not an error. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. The name must end in `Error` or `Exception`. A `warning:` line is not an error. A line that is only braces or punctuation is not an error, so the closing `}` of a Node fetch dump does not hide `ENOTFOUND`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. The ingest CLI prints `✗ : fetch failed` with no `TypeError` prefix, and that line is inconclusive when it has no HTTP status. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas diff --git a/.agents/skills/verify-synac/scripts/cli.mjs b/.agents/skills/verify-synac/scripts/cli.mjs index 3ab74771..dd4c1dd9 100755 --- a/.agents/skills/verify-synac/scripts/cli.mjs +++ b/.agents/skills/verify-synac/scripts/cli.mjs @@ -164,7 +164,7 @@ function ingestStep(report) { } const networkUnreachable = - /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b/; + /ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ECONNRESET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|getaddrinfo|Hostname did not resolve|network is unreachable|socket hang up|TypeError: fetch failed\b|[✗✘]\s+\S+:\s+fetch failed\b/; function isWrapperLine(line) { if (line.length === 0 || line === '^' || line.startsWith('\u2009')) return true; @@ -186,8 +186,14 @@ function isMarkedError(line) { return /^[✗✘]\s+\S/.test(line); } +function isBraceOrPunctuation(line) { + return /^[^\p{L}\p{N}]+$/u.test(line); +} + function isErrorLine(line) { - if (line.length === 0 || /^\s/.test(line) || isWrapperLine(line)) return false; + if (line.length === 0 || /^\s/.test(line) || isWrapperLine(line) || isBraceOrPunctuation(line)) { + return false; + } if (isMarkedError(line) || isNamedError(line)) return true; return !/: /.test(line); } @@ -219,7 +225,7 @@ export function finalThrownError(text) { block.push(line); continue; } - if (isStackFrame(line)) continue; + if (isStackFrame(line) || isBraceOrPunctuation(line)) continue; break; } return block.join('\n'); diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs index da2719ec..46b662c8 100644 --- a/.agents/skills/verify-synac/scripts/cli.test.mjs +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -122,6 +122,41 @@ test('an indented ENOTFOUND frame does not make a parser error INCONCLUSIVE', () assert.equal(ingestNetworkUnreachable(log), false); }); +test('a Node 24 fetch error dump ending in a brace is INCONCLUSIVE', () => { + const major = Number(process.versions.node.split('.')[0]); + assert.equal(major, 24); + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + 'try {', + " await fetch('https://www.rfc-editor.invalid/rfc/rfc4949.txt');", + '} catch (error) {', + ' console.error(error);', + '}', + ].join('\n'), + ], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.equal(result.status, 0); + const dump = result.stderr; + assert.match(dump, /^\[TypeError: fetch failed\] \{$/m); + assert.match(dump, /\[cause\]: Error: getaddrinfo ENOTFOUND /); + assert.equal(dump.trim().split('\n').at(-1), '}'); + assert.equal(ingestNetworkUnreachable(dump), true); +}); + +test('the ingest CLI fetch failed line is INCONCLUSIVE', () => { + const log = [ + '$ pnpm --filter @synac/ingest-tools ingest -- --source rfc4949', + '✗ rfc4949: fetch failed', + ].join('\n'); + assert.equal(ingestNetworkUnreachable(log), true); + assert.equal(ingestNetworkUnreachable('✗ rfc4949: fetch failed (404)'), false); +}); + test('cli.mjs --help runs through the .cursor skill symlink', () => { const result = spawnSync( process.execPath, From fb5018cc6bbbc7119534bd67feef550e537cdbbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 15:18:27 +0000 Subject: [PATCH 21/23] fix: classify a captured Node 24 fetch dump without a live lookup The brace test uses a Node v24.22.0 console.error fixture. A live fetch runs only when SYNAC_VERIFY_LIVE_FETCH=1 and accepts whatever network error the lookup returns. A bare fetch failed also covers TLS errors. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- .../references/features/ingest.md | 2 +- .../skills/verify-synac/scripts/cli.test.mjs | 63 ++++++++++++------- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 0c1b75c5..571512d1 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -200,7 +200,7 @@ Side effects worth reading: The local Convex sync is the same compiler output production serves. Do not stub those reads. `POST /api/v1/internal/revalidate` needs `SYNAC_REVALIDATE_SECRET` of at least 16 characters. That route is `INCONCLUSIVE` when the secret is unset. Do not invent a secret. -Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A name counts only when it ends in `Error` or `Exception` (`TypeError`, `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`), or the line is an `✗` or `✘` result, or it is an unprefixed message. A `warning:` line is not an error. A line that is only braces or punctuation, including the closing `}` of a Node error dump, is not an error. A later matching error wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, `TypeError: fetch failed`, or the ingest CLI line `✗ : fetch failed`). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. +Live `pnpm ingest -- --source rfc4949` fetches the upstream RFC. The helper copies the source into `/tmp/synac-verify-/ingest-content` so a run cannot rewrite `content/generated/` in the repo. The drive classifies only the last unindented error and an explicit cause such as `[cause]:`. A name counts only when it ends in `Error` or `Exception` (`TypeError`, `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`), or the line is an `✗` or `✘` result, or it is an unprefixed message. A `warning:` line is not an error. A line that is only braces or punctuation, including the closing `}` of a Node error dump, is not an error. A later matching error wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error `INCONCLUSIVE`. The last error is `INCONCLUSIVE` when it is a network failure with no HTTP status (`ENOTFOUND`, `ECONNREFUSED`, `ENETUNREACH`, a connect timeout, `TypeError: fetch failed`, or the ingest CLI line `✗ : fetch failed`, which also covers a TLS failure because Node uses that same message). Any HTTP status is a failure, including `fetch failed (404)`. A parser error is a failure even when an earlier retry line mentions `ENOTFOUND`. Any other non-zero ingest fails `pnpm verify:drive`. The log is `cli-ingest-rfc4949.log`. ## Cleanup diff --git a/.agents/skills/verify-synac/references/features/ingest.md b/.agents/skills/verify-synac/references/features/ingest.md index 33a29ea5..23cf6db5 100644 --- a/.agents/skills/verify-synac/references/features/ingest.md +++ b/.agents/skills/verify-synac/references/features/ingest.md @@ -45,7 +45,7 @@ SYNAC_CONTENT_DIR="$dir" pnpm --filter @synac/ingest-tools ingest -- --source rf A finished run prints `rfc4949: unchanged` or `rfc4949: updated` and leaves `$dir/generated/rfc4949.json` in place. Hash `content/generated/rfc4949.json` before and after. The repo file stays the same. -If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. The name must end in `Error` or `Exception`. A `warning:` line is not an error. A line that is only braces or punctuation is not an error, so the closing `}` of a Node fetch dump does not hide `ENOTFOUND`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. The ingest CLI prints `✗ : fetch failed` with no `TypeError` prefix, and that line is inconclusive when it has no HTTP status. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. +If the network is unreachable, record `INCONCLUSIVE` and keep the log `cli-ingest-rfc4949.log`. The drive still exits 0. Classification uses only the last unindented error and an explicit cause line such as `[cause]:`. The name must end in `Error` or `Exception`. A `warning:` line is not an error. A line that is only braces or punctuation is not an error, so the closing `}` of a Node fetch dump does not hide `ENOTFOUND`. A later `RangeError`, `ZodError`, `IngestError`, `[Error: ...]`, or unprefixed message wins over an earlier `TypeError: fetch failed`. Indented stack frames are ignored, so `ENOTFOUND` in a frame does not make a parser error inconclusive. The ingest CLI prints `✗ : fetch failed` with no `TypeError` prefix, and that line is inconclusive when it has no HTTP status. A bare `fetch failed` also covers TLS errors. Node uses that same message for a certificate or handshake failure. Any HTTP status is a failure. A parser error is a failure even when an earlier retry mentions a network error. Any other non-zero ingest fails the drive. The usage and missing-source checks still have to pass on the way to the live fetch. ## Gotchas diff --git a/.agents/skills/verify-synac/scripts/cli.test.mjs b/.agents/skills/verify-synac/scripts/cli.test.mjs index 46b662c8..5473b305 100644 --- a/.agents/skills/verify-synac/scripts/cli.test.mjs +++ b/.agents/skills/verify-synac/scripts/cli.test.mjs @@ -122,32 +122,49 @@ test('an indented ENOTFOUND frame does not make a parser error INCONCLUSIVE', () assert.equal(ingestNetworkUnreachable(log), false); }); +// Captured from Node v24.22.0 `console.error` of a failed fetch. The last line is only `}`. +const node24FetchDump = `[TypeError: fetch failed] { + [cause]: Error: getaddrinfo ENOTFOUND www.rfc-editor.invalid + at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:123:26) { + errno: -3008, + code: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'www.rfc-editor.invalid' + } +}`; + test('a Node 24 fetch error dump ending in a brace is INCONCLUSIVE', () => { - const major = Number(process.versions.node.split('.')[0]); - assert.equal(major, 24); - const result = spawnSync( - process.execPath, - [ - '--input-type=module', - '-e', - [ - 'try {', - " await fetch('https://www.rfc-editor.invalid/rfc/rfc4949.txt');", - '} catch (error) {', - ' console.error(error);', - '}', - ].join('\n'), - ], - { encoding: 'utf8', timeout: 15000 }, - ); - assert.equal(result.status, 0); - const dump = result.stderr; - assert.match(dump, /^\[TypeError: fetch failed\] \{$/m); - assert.match(dump, /\[cause\]: Error: getaddrinfo ENOTFOUND /); - assert.equal(dump.trim().split('\n').at(-1), '}'); - assert.equal(ingestNetworkUnreachable(dump), true); + assert.match(node24FetchDump, /^\[TypeError: fetch failed\] \{$/m); + assert.equal(node24FetchDump.trim().split('\n').at(-1), '}'); + assert.equal(ingestNetworkUnreachable(node24FetchDump), true); }); +test( + 'a live fetch dump is INCONCLUSIVE when SYNAC_VERIFY_LIVE_FETCH=1', + { skip: process.env.SYNAC_VERIFY_LIVE_FETCH !== '1' }, + () => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + 'try {', + " await fetch('https://www.rfc-editor.invalid/rfc/rfc4949.txt');", + '} catch (error) {', + ' console.error(error);', + '}', + ].join('\n'), + ], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.equal(result.status, 0); + const dump = result.stderr; + assert.match(dump, /fetch failed/); + assert.equal(ingestNetworkUnreachable(dump), true); + }, +); + test('the ingest CLI fetch failed line is INCONCLUSIVE', () => { const log = [ '$ pnpm --filter @synac/ingest-tools ingest -- --source rfc4949', From 543c8040b6fbff6445323ee6b43b1fb18b855343 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 15:51:26 +0000 Subject: [PATCH 22/23] fix: keep the tmux session test out of pnpm gate pnpm test and pnpm gate no longer start tmux. The checkout isolation check is pnpm test:verify-tmux, and the optional verify-smoke workflow installs tmux and runs it. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- .../verify-synac/scripts/runtime.test.mjs | 118 --------------- .../scripts/runtime.tmux.test.mjs | 137 ++++++++++++++++++ .github/workflows/verify-smoke.yml | 3 + docs/contributing/local-dev.md | 4 +- package.json | 1 + 6 files changed, 145 insertions(+), 120 deletions(-) create mode 100644 .agents/skills/verify-synac/scripts/runtime.tmux.test.mjs diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 571512d1..9a43a932 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -110,7 +110,7 @@ While iterating, run one named group and stay inside its budget: | `content-check` | `pnpm content:check` | 1 minute | | `verify-smoke` | `pnpm verify:smoke` with the stack already up | 4 minutes | -`pnpm gate` runs lint, convex lint, format, typecheck, convex typecheck, test, tagging check, content check, content history, and build. It does not launch the site and it does not run `verify:drive`. The end-to-end suite in `apps/e2e` stays `E2E_BASE_URL=http://127.0.0.1:3000 pnpm --filter @synac/e2e test:e2e` against this same stack. Use it when the change touches accessibility or a route the feature map does not list. +`pnpm gate` runs lint, convex lint, format, typecheck, convex typecheck, test, tagging check, content check, content history, and build. It does not launch the site, it does not run `verify:drive`, and it does not need tmux. The checkout session test is `pnpm test:verify-tmux`. That command needs tmux on `PATH`. The optional verify-smoke workflow installs tmux and runs it. The end-to-end suite in `apps/e2e` stays `E2E_BASE_URL=http://127.0.0.1:3000 pnpm --filter @synac/e2e test:e2e` against this same stack. Use it when the change touches accessibility or a route the feature map does not list. ## Drive diff --git a/.agents/skills/verify-synac/scripts/runtime.test.mjs b/.agents/skills/verify-synac/scripts/runtime.test.mjs index 7d9b0964..7fa8bf8d 100644 --- a/.agents/skills/verify-synac/scripts/runtime.test.mjs +++ b/.agents/skills/verify-synac/scripts/runtime.test.mjs @@ -66,10 +66,6 @@ const { runFile, stopRecordedPid, processIdentity, - sessionConvex, - sessionsFor, - stateDirFor, - tmux, } = await import('./runtime.mjs'); after(() => { @@ -536,120 +532,6 @@ test('SIGKILL waits for the launch start time to still match', async () => { } }); -function writeCheckoutRun(dir, repoRoot, sessions, identity) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - chmodSync(dir, 0o700); - writeFileSync( - path.join(dir, 'run.json'), - `${JSON.stringify( - { - sessions, - pids: { convex: identity, web: null, convexSite: null }, - startedAt: '', - webMode: 'start', - baseUrl: 'http://127.0.0.1:3000', - repoRoot, - }, - null, - 2, - )}\n`, - ); -} - -test('cleanup from a second checkout does not stop the first', async () => { - const rootB = `/tmp/synac-verify-repo-b-${process.pid}`; - mkdirSync(rootB, { recursive: true }); - const dirA = stateDirFor(root); - const dirB = stateDirFor(rootB); - const sessionsA = sessionsFor(root); - const sessionsB = sessionsFor(rootB); - assert.notEqual(dirA, dirB); - assert.match(dirA, /^\/tmp\/synac-verify-[0-9a-f]{12}$/); - assert.notEqual(sessionsA.convex, sessionsB.convex); - assert.equal(sessionConvex, sessionsA.convex); - assert.notEqual(sessionConvex, 'synac-verify-convex'); - const started = []; - const sleepA = spawn('sleep', ['60'], { stdio: 'ignore' }); - let sleepB; - try { - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('sleep A did not spawn')), 2000); - sleepA.once('spawn', () => { - clearTimeout(timer); - resolve(); - }); - sleepA.once('error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); - sleepB = spawn('sleep', ['60'], { stdio: 'ignore' }); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('sleep B did not spawn')), 2000); - sleepB.once('spawn', () => { - clearTimeout(timer); - resolve(); - }); - sleepB.once('error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); - const idA = processIdentity(sleepA.pid); - const idB = processIdentity(sleepB.pid); - assert.ok(idA); - assert.ok(idB); - writeCheckoutRun(dirA, root, [sessionsA.convex], idA); - writeCheckoutRun(dirB, rootB, [sessionsB.convex, sessionsA.convex], idB); - for (const name of [sessionsA.convex, sessionsB.convex]) { - tmux(['kill-session', '-t', name], { timeout: 5000 }); - const created = tmux(['new-session', '-d', '-s', name, 'sleep', '60'], { timeout: 5000 }); - assert.equal(created.status, 0, created.stderr); - started.push(name); - } - const env = { ...process.env }; - delete env.SYNAC_VERIFY_STATE_DIR; - delete env.VERIFY_EVIDENCE_DIR; - env.SYNAC_VERIFY_ROOT = rootB; - const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); - const result = spawnSync(process.execPath, [cleanup], { env, encoding: 'utf8', timeout: 20_000 }); - assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); - const output = `${result.stdout}\n${result.stderr}`; - assert.match(output, new RegExp(`stopped session ${sessionsB.convex}`)); - assert.doesNotMatch(output, new RegExp(`stopped session ${sessionsA.convex}`)); - assert.doesNotMatch(output, new RegExp(`\\b${sleepA.pid}\\b`)); - assert.equal(tmux(['has-session', '-t', sessionsA.convex]).status, 0); - assert.notEqual(tmux(['has-session', '-t', sessionsB.convex]).status, 0); - assert.equal(processIdentity(sleepA.pid)?.pid, sleepA.pid); - assert.match(output, new RegExp(`\\b${sleepB.pid}\\b`)); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('sleep B was not stopped')), 3000); - const done = () => { - clearTimeout(timer); - resolve(); - }; - if (sleepB.exitCode !== null || sleepB.signalCode !== null) done(); - else sleepB.once('exit', done); - }); - assert.equal(processIdentity(sleepB.pid), null); - } finally { - for (const name of started) tmux(['kill-session', '-t', name], { timeout: 5000 }); - try { - sleepA.kill('SIGKILL'); - } catch { - // already exited - } - try { - sleepB?.kill('SIGKILL'); - } catch { - // already exited - } - rmSync(rootB, { recursive: true, force: true }); - rmSync(dirA, { recursive: true, force: true }); - rmSync(dirB, { recursive: true, force: true }); - } -}); - test('runtime checks use the temp repo and leave the real checkout alone', () => { rmSync(scratchDir, { recursive: true, force: true }); assert.equal(root, repo); diff --git a/.agents/skills/verify-synac/scripts/runtime.tmux.test.mjs b/.agents/skills/verify-synac/scripts/runtime.tmux.test.mjs new file mode 100644 index 00000000..be813502 --- /dev/null +++ b/.agents/skills/verify-synac/scripts/runtime.tmux.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const realRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const repo = `/tmp/synac-verify-repo-${process.pid}`; +const state = `/tmp/synac-verify-test-${process.pid}`; + +rmSync(repo, { recursive: true, force: true }); +rmSync(state, { recursive: true, force: true }); +mkdirSync(repo, { recursive: true }); + +process.env.SYNAC_VERIFY_ROOT = repo; +process.env.SYNAC_VERIFY_STATE_DIR = state; +process.env.VERIFY_EVIDENCE_DIR = path.join(state, 'evidence'); + +// runtime.mjs binds the checkout and chdir at import, so this import stays +// after SYNAC_VERIFY_ROOT points at the temp repo. +const { root, processIdentity, sessionConvex, sessionsFor, stateDirFor, tmux } = await import( + './runtime.mjs' +); + +after(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(state, { recursive: true, force: true }); +}); + +function writeCheckoutRun(dir, repoRoot, sessions, identity) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + writeFileSync( + path.join(dir, 'run.json'), + `${JSON.stringify( + { + sessions, + pids: { convex: identity, web: null, convexSite: null }, + startedAt: '', + webMode: 'start', + baseUrl: 'http://127.0.0.1:3000', + repoRoot, + }, + null, + 2, + )}\n`, + ); +} + +function waitForSpawn(child, label) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} did not spawn`)), 2000); + child.once('spawn', () => { + clearTimeout(timer); + resolve(); + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +test('cleanup from a second checkout does not stop the first', async () => { + const rootB = `/tmp/synac-verify-repo-b-${process.pid}`; + mkdirSync(rootB, { recursive: true }); + const dirA = stateDirFor(root); + const dirB = stateDirFor(rootB); + const sessionsA = sessionsFor(root); + const sessionsB = sessionsFor(rootB); + assert.notEqual(dirA, dirB); + assert.match(dirA, /^\/tmp\/synac-verify-[0-9a-f]{12}$/); + assert.notEqual(sessionsA.convex, sessionsB.convex); + assert.equal(sessionConvex, sessionsA.convex); + assert.notEqual(sessionConvex, 'synac-verify-convex'); + const started = []; + const sleepA = spawn('sleep', ['60'], { stdio: 'ignore' }); + let sleepB; + try { + await waitForSpawn(sleepA, 'sleep A'); + sleepB = spawn('sleep', ['60'], { stdio: 'ignore' }); + await waitForSpawn(sleepB, 'sleep B'); + const idA = processIdentity(sleepA.pid); + const idB = processIdentity(sleepB.pid); + assert.ok(idA); + assert.ok(idB); + writeCheckoutRun(dirA, root, [sessionsA.convex], idA); + writeCheckoutRun(dirB, rootB, [sessionsB.convex, sessionsA.convex], idB); + for (const name of [sessionsA.convex, sessionsB.convex]) { + tmux(['kill-session', '-t', name], { timeout: 5000 }); + const created = tmux(['new-session', '-d', '-s', name, 'sleep', '60'], { timeout: 5000 }); + assert.equal(created.status, 0, created.stderr); + started.push(name); + } + const env = { ...process.env }; + delete env.SYNAC_VERIFY_STATE_DIR; + delete env.VERIFY_EVIDENCE_DIR; + env.SYNAC_VERIFY_ROOT = rootB; + const cleanup = path.join(realRoot, '.agents/skills/verify-synac/scripts/cleanup.mjs'); + const result = spawnSync(process.execPath, [cleanup], { env, encoding: 'utf8', timeout: 20_000 }); + assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, new RegExp(`stopped session ${sessionsB.convex}`)); + assert.doesNotMatch(output, new RegExp(`stopped session ${sessionsA.convex}`)); + assert.doesNotMatch(output, new RegExp(`\\b${sleepA.pid}\\b`)); + assert.equal(tmux(['has-session', '-t', sessionsA.convex]).status, 0); + assert.notEqual(tmux(['has-session', '-t', sessionsB.convex]).status, 0); + assert.equal(processIdentity(sleepA.pid)?.pid, sleepA.pid); + assert.match(output, new RegExp(`\\b${sleepB.pid}\\b`)); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('sleep B was not stopped')), 3000); + const done = () => { + clearTimeout(timer); + resolve(); + }; + if (sleepB.exitCode !== null || sleepB.signalCode !== null) done(); + else sleepB.once('exit', done); + }); + assert.equal(processIdentity(sleepB.pid), null); + } finally { + for (const name of started) tmux(['kill-session', '-t', name], { timeout: 5000 }); + try { + sleepA.kill('SIGKILL'); + } catch { + // already exited + } + try { + sleepB?.kill('SIGKILL'); + } catch { + // already exited + } + rmSync(rootB, { recursive: true, force: true }); + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/verify-smoke.yml b/.github/workflows/verify-smoke.yml index 90805bc6..719b8cd1 100644 --- a/.github/workflows/verify-smoke.yml +++ b/.github/workflows/verify-smoke.yml @@ -41,6 +41,9 @@ jobs: - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux + - name: Session isolation test + run: pnpm test:verify-tmux + - name: Install Playwright chromium run: pnpm --filter @synac/e2e exec playwright install --with-deps chromium diff --git a/docs/contributing/local-dev.md b/docs/contributing/local-dev.md index fe2d8bd7..edc031c1 100644 --- a/docs/contributing/local-dev.md +++ b/docs/contributing/local-dev.md @@ -69,7 +69,9 @@ CI runs the same list, so a green local gate means a green CI. Run the gate before you push, not after CI tells you. `pnpm gate` does not run the end-to-end suite. The `Quality` workflow runs -that. +that. `pnpm gate` also does not need tmux. The verification skill's checkout +session test is `pnpm test:verify-tmux`, and that command needs tmux on +`PATH`. ## End-to-end tests diff --git a/package.json b/package.json index bb518da6..80e3de33 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "typecheck": "corepack pnpm -r typecheck", "typecheck:convex": "tsc -p convex/tsconfig.json --noEmit", "test": "corepack pnpm -r --workspace-concurrency=1 test && corepack pnpm test:convex && node --test .agents/skills/verify-synac/scripts/cli.test.mjs .agents/skills/verify-synac/scripts/runtime.test.mjs", + "test:verify-tmux": "node --test .agents/skills/verify-synac/scripts/runtime.tmux.test.mjs", "test:convex": "vitest run -c vitest.convex.config.ts", "tagging:check": "corepack pnpm --dir experiments/tagging/synthetic-reference run check && corepack pnpm --dir experiments/tagging/production-backfill run check", "format": "corepack pnpm -r format", From 488d57924fa0b50022d494b61a049a91e93a5352 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 15:55:25 +0000 Subject: [PATCH 23/23] fix: run the tmux session test in required CI pnpm test and pnpm gate still do not start tmux. The CI workflow installs tmux after the normal tests and runs pnpm test:verify-tmux. Co-authored-by: Aman Thanvi --- .agents/skills/verify-synac/SKILL.md | 2 +- .github/workflows/ci.yml | 8 ++++++++ docs/contributing/local-dev.md | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-synac/SKILL.md b/.agents/skills/verify-synac/SKILL.md index 9a43a932..4ef53df0 100644 --- a/.agents/skills/verify-synac/SKILL.md +++ b/.agents/skills/verify-synac/SKILL.md @@ -110,7 +110,7 @@ While iterating, run one named group and stay inside its budget: | `content-check` | `pnpm content:check` | 1 minute | | `verify-smoke` | `pnpm verify:smoke` with the stack already up | 4 minutes | -`pnpm gate` runs lint, convex lint, format, typecheck, convex typecheck, test, tagging check, content check, content history, and build. It does not launch the site, it does not run `verify:drive`, and it does not need tmux. The checkout session test is `pnpm test:verify-tmux`. That command needs tmux on `PATH`. The optional verify-smoke workflow installs tmux and runs it. The end-to-end suite in `apps/e2e` stays `E2E_BASE_URL=http://127.0.0.1:3000 pnpm --filter @synac/e2e test:e2e` against this same stack. Use it when the change touches accessibility or a route the feature map does not list. +`pnpm gate` runs lint, convex lint, format, typecheck, convex typecheck, test, tagging check, content check, content history, and build. It does not launch the site, it does not run `verify:drive`, and it does not need tmux. The checkout session test is `pnpm test:verify-tmux`. That command needs tmux on `PATH`. Required CI installs tmux and runs it after `pnpm test`. The optional verify-smoke workflow does the same. The end-to-end suite in `apps/e2e` stays `E2E_BASE_URL=http://127.0.0.1:3000 pnpm --filter @synac/e2e test:e2e` against this same stack. Use it when the change touches accessibility or a route the feature map does not list. ## Drive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65121c41..9c351745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,14 @@ jobs: - name: Test run: pnpm test + # Not part of `pnpm gate`. The session test needs tmux, which the + # default test script does not install or invoke. + - name: Install tmux + run: sudo apt-get update && sudo apt-get install -y tmux + + - name: Verify session isolation + run: pnpm test:verify-tmux + - name: Tagging check run: pnpm tagging:check diff --git a/docs/contributing/local-dev.md b/docs/contributing/local-dev.md index edc031c1..2d770055 100644 --- a/docs/contributing/local-dev.md +++ b/docs/contributing/local-dev.md @@ -71,7 +71,7 @@ before you push, not after CI tells you. `pnpm gate` does not run the end-to-end suite. The `Quality` workflow runs that. `pnpm gate` also does not need tmux. The verification skill's checkout session test is `pnpm test:verify-tmux`, and that command needs tmux on -`PATH`. +`PATH`. Required CI installs tmux and runs that command after `pnpm test`. ## End-to-end tests