diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 640105e..cfab96e 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -1,14 +1,14 @@ --- name: bump-version description: > - Set the laud version across every package.json and promote the CHANGES.md + Set the ailoud version across every package.json and promote the CHANGES.md Development section to a released Version section, then create the release commit. Does not tag or push. --- # bump-version -Bump the laud version and prepare the release commit. Tagging and pushing +Bump the ailoud version and prepare the release commit. Tagging and pushing remain separate, deliberate steps. ## Steps @@ -44,7 +44,7 @@ Run `git diff` and confirm the version bump touched exactly these files: `## Development`). No other files changed. There is no `Cargo.toml` and no -`tauri.conf.json` in this project -- laud is a pure TypeScript workspace. +`tauri.conf.json` in this project -- ailoud is a pure TypeScript workspace. ### 4. Create the release commit diff --git a/.agents/skills/check-dependencies/SKILL.md b/.agents/skills/check-dependencies/SKILL.md new file mode 100644 index 0000000..cdaf2b2 --- /dev/null +++ b/.agents/skills/check-dependencies/SKILL.md @@ -0,0 +1,141 @@ +--- +name: check-dependencies +description: > + Audit npm dependencies for known vulnerabilities, report who asks for + funding, and update what is behind -- refusing any version published less + than 14 days ago unless it fixes a critical advisory. Run before every + release and after any dependency change. +--- + +# check-dependencies + +Three questions, in this order: is anything we depend on known to be +vulnerable, who is asking to be paid for it, and what should be updated. +Updating comes last because the first two decide what an update is for. + +## Steps + +1. **Look for known vulnerabilities.** + + ```bash + pnpm audit # everything, including the dev toolchain + pnpm audit --prod # only what the three published packages ship + ``` + + Read them separately. A high-severity advisory in a test runner is a + different problem from one in `commander`: the first cannot reach a user, + the second is installed on their machine. Fix the `--prod` findings first + and say which list a finding came from. + +2. **Report funding.** + + ```bash + npm fund + ``` + + `pnpm` has no `fund` command; `npm fund` reads the installed tree and works + in this workspace. The list is long and almost all of it is the dev + toolchain. What is worth surfacing is the intersection with what actually + ships: + + ```bash + npm fund --json | node -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)).on("end", () => { + const names = []; + const rec = (n) => { + for (const [name, child] of Object.entries(n.dependencies ?? {})) { + names.push(name); + rec(child); + } + }; + rec(JSON.parse(s)); + const runtime = new Set(); + for (const p of ["packages/core", "packages/providers", "apps/cli"]) { + Object.keys(require(`./${p}/package.json`).dependencies ?? {}).forEach((k) => + runtime.add(k), + ); + } + console.log(names.filter((n) => runtime.has(n)).join(", ") || "(none)"); + })' + ``` + + Report the count and that intersection. Do not open funding pages or + install anything. + +3. **See what is behind.** + + ```bash + pnpm outdated -r + ``` + + Nothing here is urgent by default. A version bump is worth taking when it + fixes something this project hits, closes an advisory from step 1, or keeps + a major version from drifting far enough to become a project of its own. + "It is newer" is not a reason. + +4. **Apply the 14-day rule BEFORE updating anything.** + + ```bash + node scripts/check-dependency-age.mjs + ``` + + A compromised release is discovered by other people, and that takes days -- + every npm supply-chain incident in recent memory was caught within a week + or two of publication, after everyone who upgraded immediately had already + installed it. Waiting costs nothing: there is no urgency in a patch that + has been out for two weeks that was not there on day one. + + So a candidate version younger than 14 days is not taken. Pin the previous + one and come back to it. + + **Unless it fixes a critical or high advisory.** Then waiting is the worse + risk -- a known exploit beats an unaudited release. Record the decision in + `scripts/dependency-age-exceptions.json` with the advisory ID: + + ```json + { + "some-package@2.0.1": "fixes GHSA-xxxx-yyyy-zzzz (critical), 2026-09-05" + } + ``` + + The exception is a statement about one moment, so the check reports entries + that have aged out and should be deleted. Never add one to silence the + check for convenience; if there is no advisory ID, there is no exception. + +5. **Update carefully.** + + One concern per commit, so a regression is bisectable: + + ```bash + pnpm update --latest # or edit the manifest and pnpm install + ``` + + - Keep versions **exact**. This project pins them; a range hands the + decision to whatever resolved last, and the age check cannot judge it. + - Run the `check-licenses` skill if any `package.json` changed -- a new + version can change its licence. + - Run the `run-tests-and-linters` skill. A dependency update that passes + nothing is not an update. + - A user-visible consequence goes in CHANGES.md; a toolchain bump does not. + +6. **Verify the result.** + + ```bash + pnpm audit --prod + node scripts/check-dependency-age.mjs + ``` + + Both must pass. Report what changed, what was deliberately left behind and + why, and any advisory that remains open with the reason it cannot be closed + yet. + +## Do not + +- Do not run `pnpm audit --fix` or `npm audit fix`. They resolve to whatever + is newest, which is the version the 14-day rule exists to refuse. +- Do not update a major version as part of a release. It is its own change, + with its own testing. +- Do not treat a clean `pnpm audit` as proof of anything beyond "no advisory + has been published". Most of what the age rule protects against has no + advisory yet. diff --git a/.agents/skills/check-docs/SKILL.md b/.agents/skills/check-docs/SKILL.md index 2cc06fb..63c1310 100644 --- a/.agents/skills/check-docs/SKILL.md +++ b/.agents/skills/check-docs/SKILL.md @@ -22,11 +22,17 @@ release, or right after adding or changing a CLI command or option. - Confirm every `pnpm` command shown in README.md exists as a script in the root `package.json` (`pnpm lint`, `pnpm typecheck`, `pnpm test:cov`, `pnpm build`, `pnpm format:check`, `pnpm test:e2e`, etc.). - - Confirm every `laud` CLI command shown (`import`, `transcribe`, `ls`, - `show`, `doctor`) is a command M1 actually ships, per the "Project - Overview" section of AGENTS.md. `laud` has no `search`, `collection`, - `tag`, `summarize`, `export`, or `config` command yet; flag any of - those names if they appear in README.md or AGENTS.md. + - Confirm every `ailoud` command shown really exists, by asking the + BINARY rather than any prose: + `node apps/cli/dist/bin/ailoud.js --help` after `pnpm build`, then + `--help` on each group and verb. AGENTS.md says outright not to trust a + list of commands in prose over the binary, and this skill used to name a + fixed set of commands and assert that `search` and `summarize` did not + exist -- both had shipped long before anyone noticed the skill was + telling agents otherwise. + Note that zsh does NOT word-split an unquoted variable, so `$cmd --help` + with `cmd="audio ls"` passes one argument and commander prints the + TOP-LEVEL help, making every subcommand look like it has no flags. - Confirm every relative link in README.md and AGENTS.md resolves to a file that exists in the repository. Links into `.superpowers/` are a defect: that directory is git-ignored and absent from a fresh clone. @@ -45,7 +51,13 @@ release, or right after adding or changing a CLI command or option. 5. **Check version references.** Search `README.md`, `AGENTS.md` and `CONTRIBUTING.md` for version strings. Any hardcoded version must match the version in root - `package.json`. This project ships no documentation site. + `package.json`. + + This project DOES ship a documentation site: `docs/` built with mkdocs and + published per release to `gh-pages`. Build it with `pnpm docs:build` + (strict) and check the rendering with `node scripts/check-docs-render.mjs` + -- the strict build cannot see an admonition whose content fell outside its + box, which has shipped here before. 6. **Check CHANGES.md structure.** Confirm the file starts with a `## Development` section and that diff --git a/.agents/skills/check-fixtures/SKILL.md b/.agents/skills/check-fixtures/SKILL.md index 48451bd..6d27d26 100644 --- a/.agents/skills/check-fixtures/SKILL.md +++ b/.agents/skills/check-fixtures/SKILL.md @@ -1,7 +1,7 @@ --- name: check-fixtures description: > - Drive the built laud binary against fixtures/ end to end -- import, + Drive the built ailoud binary against fixtures/ end to end -- import, transcribe, ls, show, and doctor -- in a throwaway HOME, XDG_CONFIG_HOME, and XDG_DATA_HOME, and confirm the working tree stays clean afterward. --- @@ -10,7 +10,7 @@ description: > The unit tests cover the domain core against fakes (`MemFs`, `FakeClock`, `FakeIds`, `FakeStt`). This skill covers the layer they cannot: the real -`laud` binary, against real audio, writing to a real filesystem (inside a +`ailoud` binary, against real audio, writing to a real filesystem (inside a sandbox). It is the only check that would catch a regression living in the wiring between the CLI, the providers, and the filesystem -- for example a provider writing to the wrong data directory, or a pipeline that behaves @@ -28,14 +28,14 @@ right lens" below. Three short fixtures, each with a reference transcript: an English clip, a Russian clip, and a clip that mixes both languages. The suite drives: -- `laud doctor` against a sandbox with no config, and again after +- `ailoud doctor` against a sandbox with no config, and again after configuring the model. -- `laud import` against a fixture file, including the "already present" +- `ailoud import` against a fixture file, including the "already present" path on a repeat import. -- `laud transcribe`, checked by word error rate against the reference +- `ailoud transcribe`, checked by word error rate against the reference transcript rather than exact string equality -- a model or quantization change shifts wording by a word or two without being a regression. -- `laud show` in both `srt` and `json` formats, plus its error paths (a +- `ailoud show` in both `srt` and `json` formats, plus its error paths (a missing id, an unsupported `--format`). ## Isolation @@ -44,7 +44,7 @@ The suite must never touch the developer's machine state. Every invocation of the built binary sets all three of: - `XDG_CONFIG_HOME`, which relocates `config.yaml`. -- `XDG_DATA_HOME`, which relocates `laud.db` and the `media/` tree. +- `XDG_DATA_HOME`, which relocates `ailoud.db` and the `media/` tree. - `HOME`, so nothing the process resolves relative to the real home directory (for example a fallback default when an XDG variable is unset) can reach outside the sandbox. diff --git a/.agents/skills/check-licenses/SKILL.md b/.agents/skills/check-licenses/SKILL.md index e6bfe48..741aac9 100644 --- a/.agents/skills/check-licenses/SKILL.md +++ b/.agents/skills/check-licenses/SKILL.md @@ -11,7 +11,7 @@ description: > Verify that every direct npm dependency is license-compatible with Apache-2.0 and keep the Third-Party Notices section of `LICENSE` up to -date. `laud` is a pure TypeScript pnpm workspace -- there is no cargo +date. `ailoud` is a pure TypeScript pnpm workspace -- there is no cargo workspace to check, unlike the source repository this project inherits its conventions from. @@ -71,7 +71,7 @@ dependencies` subsection's table -- replace from its `| Package |` | | npm | | | | ``` - Every row's Ecosystem column is `npm` -- laud has no other dependency + Every row's Ecosystem column is `npm` -- ailoud has no other dependency ecosystem. List rows alphabetically by package name. Preserve everything above the table verbatim: the Apache 2.0 license text, the `## Third-Party Notices` heading, and the intro paragraph above the diff --git a/.agents/skills/check-migrations/SKILL.md b/.agents/skills/check-migrations/SKILL.md new file mode 100644 index 0000000..85496fe --- /dev/null +++ b/.agents/skills/check-migrations/SKILL.md @@ -0,0 +1,130 @@ +--- +name: check-migrations +description: > + After touching packages/core/src/db/schema.ts -- verify a new migration + carries a lock entry and an up-to-date snapshot, and judge the one thing + the guard tests cannot see: whether an edited migration has already + shipped. +--- + +# check-migrations + +`MIGRATIONS` in `packages/core/src/db/schema.ts` is append-only: every entry +that has reached a released version is a promise to every database that has +already run it. Two tests in +`packages/providers/src/store/schemaGuard.test.ts` enforce most of that +promise mechanically. This skill runs them, reads what they show you, and +judges the part they structurally cannot: whether a migration you are +looking at has shipped. + +## When to use it + +After any change to `packages/core/src/db/schema.ts` -- a new migration, or +an edit to an existing one -- before it goes into a commit. + +## What the guard cannot see + +A test can hash a migration and compare it to a database dump. Neither can +tell you when the migration was written. That is the whole reason this is a +skill and not a third test: + +- **Editing a migration that has not shipped yet is fine.** If it was added + in this branch and nothing has run it, changing it is just editing a + patch before it lands -- update the lock and the snapshot together with + the edit, in the same commit. +- **Editing a migration that has already shipped is not fine**, no matter + how small the change. A database that ran it at an older version will + never run it again, so an edited version means two machines at the same + `user_version` with different schemas -- and neither the fingerprint test + nor the snapshot test can tell you this from the file alone, because both + only ever see the current content of `schema.ts`. +- **Deleting a shipped migration is the same hazard, not a different one.** + A database that already ran it does not forget; removing its entry from + `MIGRATIONS` just means the promise that entry made is no longer written + down anywhere for the next person to honor. Treat "remove this migration" + the same as "edit this migration": fine if it never shipped, never fine if + it did. +- **Reordering the `MIGRATIONS` array is the same hazard again**, even with + every `version` field left untouched -- a migration's position in the + array is not what makes it safe to change, its `version` having already + run somewhere is. (The lock-completeness test happens to catch a reorder + today, because it compares sorted lock keys against `MIGRATIONS` in + array order, but that is not the same as the skill telling you to look + for it.) +- **Only git history tells the two apart.** Before touching an existing + migration entry, run: + + ```bash + git log --oneline -- packages/core/src/db/schema.ts + ``` + + and check whether the commit that introduced this migration's `version` + has been released (tagged, merged to `main` and shipped) or is still + local to the current branch. If it has shipped, the change belongs in a + **new** migration with the next version number, never as an edit to the + old one -- even to fix a typo in a column name. + +## Steps + +1. **Run the two tests.** + + ```bash + pnpm build + NODE_OPTIONS=--disable-warning=ExperimentalWarning pnpm vitest run packages/providers/src/store/schemaGuard.test.ts + ``` + +2. **If "has a lock entry for every migration, and no extras" fails:** + A migration was added or removed without regenerating the lock. Run: + + ```bash + node scripts/write-schema-snapshot.mjs + ``` + + then re-run the test. This is safe whenever the mismatch is a missing or + extra entry -- it is never safe to run this just because the fingerprint + test below is failing on a migration that has already shipped. + +3. **If "still matches the fingerprint of every shipped migration" fails:** + Read which migration's fingerprint changed and check git history per the + section above. + - Unshipped: run `node scripts/write-schema-snapshot.mjs` to update the + lock, and continue. + - Shipped: do not regenerate anything. Revert the edit to that migration + and put the intended change in a new migration instead. + +4. **If "produces exactly the snapshotted schema" fails:** read the diff + Vitest prints between the snapshotted schema and the one the migrations + now produce. Confirm the diff is exactly the change you intended -- a new + table, an added column, an added trigger -- and nothing incidental (for + example a different SQLite version rewriting an unrelated table's + `CREATE` text). Once confirmed, run: + + ```bash + node scripts/write-schema-snapshot.mjs + ``` + + and re-run the test. + +5. **For a genuinely new migration, confirm the comment.** Every existing + migration explains, in a comment above its statements, why the table or + column exists rather than merely what it is. Read the new migration and + confirm it carries one too -- the guard tests cannot check this, because + the comment lives outside the hashed statement strings by design (see + the comment on `fingerprint` in `schemaGuard.test.ts`). + +6. **Re-run the whole gate.** `schemaGuard.test.ts` is one file among many; + finish with the `run-tests-and-linters` skill before calling the change + done. + +## Report + +``` +schemaGuard.test.ts: PASS / FAIL +lock regenerated: yes / no +snapshot regenerated: yes / no +new migration comment: present / missing +shipped migration edited: yes (blocking) / no +``` + +If a shipped migration was edited, say so plainly and do not propose +regenerating the lock as the fix -- the fix is a new migration. diff --git a/.agents/skills/dev-tag/SKILL.md b/.agents/skills/dev-tag/SKILL.md new file mode 100644 index 0000000..f0d248a --- /dev/null +++ b/.agents/skills/dev-tag/SKILL.md @@ -0,0 +1,103 @@ +--- +name: dev-tag +description: > + Cut a throwaway development tag (`v-dev.`) to publish a snapshot + to npm under the `dev` dist-tag and exercise the release pipeline. Publishes + no documentation and never moves `latest`. +--- + +# dev-tag + +A development tag publishes a snapshot nobody will get by accident, so a real +install of real code can be tried before a release is promised. + +## The three kinds of tag + +| Tag | Cut from | npm dist-tag | Docs published | +| -------------- | ---------- | ------------ | -------------- | +| `v1.2.3-dev.1` | any branch | `dev` | no | +| `v1.2.3-rc.1` | `develop` | `next` | no | +| `v1.2.3` | `main` | `latest` | yes | + +Only a final tag moves `latest` and publishes documentation. `npm install +ailoud` therefore never picks up a dev tag, and the site never describes a +version nobody can install. + +Except once per package: npm sets `latest` on a FIRST publish whatever `--tag` +says, and `latest` cannot be removed afterwards, only moved. If a dev tag is +what introduces a package to the registry -- as v1.0.0-dev.1 was -- then +`latest` points at that snapshot until a final version is published. + +## Cutting one + +1. The working tree must be clean and the gate green: + + ``` + pnpm build && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:cov + ``` + +2. Set the version across the manifests. `-dev.` is a pre-release, so it + sorts BELOW the release it precedes: + + ``` + node scripts/bump-version.mjs 1.2.3-dev.1 + ``` + + `bump-version` promotes the CHANGES.md `## Development` section, which a dev + tag does not want -- so for a dev tag, set the versions by hand and leave + the changelog alone: + + ``` + node -e "for (const p of ['package.json','packages/core/package.json','packages/providers/package.json','apps/cli/package.json']) { const fs=require('fs'); const d=JSON.parse(fs.readFileSync(p,'utf8')); d.version='1.2.3-dev.1'; fs.writeFileSync(p, JSON.stringify(d,null,2)+'\n'); }" + ``` + +3. Commit, tag and push. The publish workflow checks that every manifest + agrees with the tag, so these cannot drift: + + ``` + git commit -am "chore: 1.2.3-dev.1" + git tag -s v1.2.3-dev.1 -m "v1.2.3-dev.1" + git push origin HEAD --tags + ``` + +4. Try it from the registry, which is the whole point: + + ``` + npm install -g ailoud@dev + ailoud --version + ``` + +## What it does not do + +- It does not publish documentation. `docs.yml` starts only on a final tag and + refuses a pre-release twice over. +- It does not move `latest`. +- It is not a release candidate. An `-rc.` tag says "this is what the release + will be"; a `-dev.` tag says "this is a snapshot to try". + +## Cleaning up + +A dev version cannot be unpublished after 72 hours and its number can never be +reused, so use a fresh `-dev.` each time rather than retrying one. + +Once the final release is out, retire every snapshot it supersedes in one step. +**By hand, and nothing will remind you** -- the release workflow cannot do it, +and a release looks finished without it: + +``` +pnpm retire 1.2.3 # prints the plan +NPM_TOKEN=npm_... pnpm retire 1.2.3 --yes # carries it out +``` + +`NPM_TOKEN` is a granular access token with read-and-write on the three +packages, and it is required for `--yes`: without one npm asks for a 2FA code +on each of the twelve writes, or waits on an interactive login. The plan needs +no credential. + +It deprecates the versions rather than unpublishing them, moves the `dev` +dist-tag onto the release rather than removing it, and deletes the tags -- but +only those whose commit is reachable from `main`. The provenance of a published package names both the commit and the tag +it was built from: delete the tag and the name stops resolving, which costs +convenience; delete a tag holding the only reference to its commit and the +commit itself can be collected, which costs the attestation its subject. Tags +in the second case are reported and left alone. diff --git a/.agents/skills/pre-release-check/SKILL.md b/.agents/skills/pre-release-check/SKILL.md index d6dcaad..7cfc65e 100644 --- a/.agents/skills/pre-release-check/SKILL.md +++ b/.agents/skills/pre-release-check/SKILL.md @@ -1,10 +1,10 @@ --- name: pre-release-check description: > - Gate a release by running check-licenses, run-tests-and-linters, - check-fixtures, check-docs, and check-changes, plus verifying the version - bump and that all commits since the last release follow the - conventional-commits format. + Gate a release by running check-licenses, check-dependencies, + run-tests-and-linters, check-fixtures, check-docs, and check-changes, plus + verifying the version bump and that all commits since the last release + follow the conventional-commits format. --- # pre-release-check @@ -21,8 +21,11 @@ details): 1. **check-licenses** -- must run first because a license failure is the most fundamental blocker. -2. **run-tests-and-linters** -- lint, typecheck, and coverage at 90%. -3. **check-fixtures** -- drive the built binary against `fixtures/` end to +2. **check-dependencies** -- advisories, funding, and the 14-day rule on any + version taken. Runs early: an update it recommends changes what everything + below is testing, so taking one afterwards invalidates the whole gate. +3. **run-tests-and-linters** -- lint, typecheck, and coverage at 90%. +4. **check-fixtures** -- drive the built binary against `fixtures/` end to end. This is the only check that exercises the real binary against real files, so it catches wiring regressions the unit tests (which run against in-memory fakes) cannot see. Six of the twelve specs need a real @@ -31,12 +34,14 @@ details): not a release blocker on its own -- attribute every failure (missing whisper-cli, fixture drift, product change, harness defect) before deciding whether it blocks. -4. **check-docs** -- README.md, cross-references, command accuracy, +5. **check-docs** -- README.md, cross-references, command accuracy, version references. -5. **check-changes** -- CHANGES.md Development section vs. commit history. +6. **check-changes** -- CHANGES.md Development section vs. commit history. -If check-licenses or run-tests-and-linters fails, report the failure and -stop. If check-fixtures fails for a reason other than the missing +If check-licenses, check-dependencies or run-tests-and-linters fails, report +the failure and stop. A `--prod` advisory or a version younger than 14 days +blocks a release: the first ships a known vulnerability, and the second ships +a version nobody has had time to find one in. If check-fixtures fails for a reason other than the missing `whisper-cli` binary (fixture drift, a product change, or a harness defect), treat it the same way -- a release must not ship while tests, licenses, or the end-to-end run are failing for a reason within this @@ -93,6 +98,7 @@ Produce a release-readiness summary: ``` check-licenses: PASS / FAIL +check-dependencies: PASS / FAIL (advisories: prod / dev only; ages: ok / N too young) run-tests-and-linters: PASS / FAIL check-fixtures: PASS / FAIL (attribute: missing whisper-cli / fixture drift / product change / harness defect) check-docs: PASS / FAIL @@ -108,3 +114,18 @@ If the gate is PASS, confirm: "All checks passed. The release may proceed." If the gate is FAIL, list every blocking issue with enough detail for the developer to act immediately. Do not tag or publish a release while any required check is failing. + +## After the release lands + +The gate ends at the tag. One step remains that no workflow performs, and a +release looks complete without it -- so report it as outstanding whenever this +skill runs for a final version: + +``` +pnpm retire # prints the plan +NPM_TOKEN=npm_... pnpm retire --yes # carries it out +``` + +It deprecates the `-dev.*` and `-rc.*` snapshots on npm, moves the `dev` dist-tag +onto the release, and deletes the superseded tags. Until it runs, someone +installing `ailoud@dev` gets an older build than `ailoud`. diff --git a/.agents/skills/run-tests-and-linters/SKILL.md b/.agents/skills/run-tests-and-linters/SKILL.md index 3b42bdb..685906d 100644 --- a/.agents/skills/run-tests-and-linters/SKILL.md +++ b/.agents/skills/run-tests-and-linters/SKILL.md @@ -9,7 +9,7 @@ description: > # run-tests-and-linters -Run the full quality gate for laud before marking any task done. `laud` is +Run the full quality gate for ailoud before marking any task done. `ailoud` is a pure TypeScript pnpm workspace, so the gate has no Rust or native half to run alongside it. diff --git a/.gitattributes b/.gitattributes index 4ac1b58..f92e46c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,13 @@ *.mp3 filter=lfs diff=lfs merge=lfs -text *.m4a filter=lfs diff=lfs merge=lfs -text +# Video fixtures: `ailoud audio import` accepts video because a meeting +# recording usually is one, and the four containers it maps to a MIME type each +# get one. They are tiny (under 15 kB) but binary, so LFS keeps them out of the +# packfile like the audio. +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.mkv filter=lfs diff=lfs merge=lfs -text +*.webm filter=lfs diff=lfs merge=lfs -text + pnpm-lock.yaml linguist-generated=true diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml new file mode 100644 index 0000000..3ac6725 --- /dev/null +++ b/.github/actions/setup-node-pnpm/action.yml @@ -0,0 +1,38 @@ +# The Node and pnpm setup every job shares, in one place because it is four +# jobs across two workflows and the corepack part needs care. +# +# `corepack enable` only writes shims; the pinned pnpm is downloaded lazily by +# whatever invokes it first, which used to be setup-node's cache probe. That +# download has failed there -- an undici assertion inside Node 24.20.0 when the +# connection ends mid-response -- and a failure inside another action's step +# cannot be retried. Downloading it here instead puts the network call in a +# step of our own, so a flake costs five seconds rather than the run. + +name: Set up Node.js and pnpm +description: Activate the pinned pnpm, then Node 24 with the pnpm store cached. + +runs: + using: composite + steps: + - name: Activate the pnpm pinned in package.json + shell: bash + env: + # Without this corepack prints a download notice that GitHub renders as + # an error annotation on a run that is fine. + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + run: | + corepack enable + for attempt in 1 2 3; do + if corepack install; then break; fi + echo "corepack install failed (attempt ${attempt} of 3); retrying in 5s" + sleep 5 + done + # Fails the step if all three attempts did, rather than leaving the + # next job step to fail with something less specific. + pnpm --version + + - name: Set up Node.js 24 with pnpm cache + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: pnpm diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..96be48f --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,34 @@ +# CodeQL configuration for AILoud. +# +# Queries are named here rather than in the workflow's `queries:` input so the +# reason for each exclusion can sit next to it. + +name: AILoud + +queries: + - uses: security-and-quality + +query-filters: + # js/file-system-race, three times over, always on the exclusive-lock + # takeover in apps/cli/src/exclusiveLock.ts (originally apps/cli/src/ + # setupLock.ts, before that logic was generalised for background jobs). + # + # That file's whole job is to be the thing a file-system race goes through. + # It acquires with `open(path, 'wx')` -- one atomic syscall -- and takes over + # a stale lock under a SECOND lock file (`.steal`, also `wx`), which is + # what makes the takeover exclusive. The analysis sees a read of one path + # followed by a write to it and reports a race; it cannot see that the + # exclusion is held by a different file. + # + # Two of the three reports were nonetheless real, and both are fixed: the + # first version deleted a live lock, the second let two runs each read back + # their own pid and proceed. What stands in for this query now is a + # concurrency test (`setupLock.test.ts`, "under contention") that runs two + # processes against the compiled lock and fails on either old implementation + # -- an actual measurement rather than a pattern match. + # + # Excluded by id, so the rule is off everywhere. It has produced nothing but + # this file and test code in this repository; if that changes, the honest fix + # is to narrow this with a `paths` filter rather than keep the noise. + - exclude: + id: js/file-system-race diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..480e7d6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,59 @@ +# Dependabot, held to the same rule as a human: nothing younger than 14 days. +# +# `cooldown` is what makes that true. Without it Dependabot opens a pull +# request the moment a version appears, which is precisely the window +# `scripts/check-dependency-age.mjs` exists to refuse -- the check would then +# fail on Dependabot's own branch and the two would be arguing. +# +# Security updates deliberately ignore cooldown, and that is the behaviour we +# want: a known advisory beats an unaudited release, which is the same +# exception `scripts/dependency-age-exceptions.json` records for a human. +# +# See .agents/skills/check-dependencies/SKILL.md for the reasoning, and +# AGENTS.md for how a dependency change reaches a release. + +version: 2 + +updates: + - package-ecosystem: npm + # The workspace root: one pnpm-lock.yaml covers all four manifests. + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 14 + # Exact pins are this project's convention, and `increase` preserves them. + # `widen` would turn a pin into a range and hand the choice of version to + # whatever resolved last, which no age check can judge. + versioning-strategy: increase + open-pull-requests-limit: 5 + groups: + # The dev toolchain moves constantly and cannot reach a user, so it + # arrives as one reviewable pull request. Anything that ships gets its + # own, because a runtime dependency deserves its own reading. + dev-toolchain: + dependency-type: development + ignore: + # typescript-eslint 8.67.0 refuses to load against TS 7.0 outright + # ("typescript-eslint does not support TS 7.0"), so the bump Dependabot + # opened could not lint -- it is blocked by a peer, not by us. Tracked as + # typescript-eslint#10940; drop this entry when it supports TS >= 7.1. + - dependency-name: typescript + update-types: ['version-update:semver-major'] + labels: + - dependencies + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 14 + groups: + actions: + patterns: + - '*' + labels: + - dependencies diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index 36a3d5d..e47c8f7 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -27,13 +27,21 @@ jobs: open-backmerge-pr: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Skip if develop already contains main id: check run: | + set -uo pipefail + # Said plainly rather than dying on `fatal: Not a valid object name`, + # which is what the first run did before develop existed. + if ! git rev-parse --verify --quiet origin/develop >/dev/null; then + echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "::notice::there is no develop branch, so there is nothing to back-merge into." + exit 0 + fi if git merge-base --is-ancestor origin/main origin/develop; then echo "needs_pr=false" >> "$GITHUB_OUTPUT" echo "develop already contains main; nothing to do." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e8bfca..325d435 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ # - `e2e` runs the specs that need no external tools -- `mcp install`, # `uninstall` and `update`, which only read and write configuration. On # every push and pull request, because it costs seconds. -# - `e2e-tools` provisions ffmpeg, whisper.cpp and a 488 MB model with +# - `e2e-tools` provisions ffmpeg, whisper.cpp and a 574 MB model with # `ailoud setup`, then runs the whole suite. On pushes only: paying that # download on every pull request would not be worth the wall clock, and # the models are cached between runs so the usual cost is the install, not @@ -46,18 +46,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: lfs: true - - name: Enable pnpm via corepack - run: corepack enable - - - name: Set up Node.js 24 with pnpm cache - uses: actions/setup-node@v5 - with: - node-version: '24' - cache: pnpm + - name: Set up Node.js 24 and pnpm + uses: ./.github/actions/setup-node-pnpm - name: Install ffmpeg # packages/providers/src/audio/ffmpeg.test.ts spawns the real binary to @@ -96,18 +90,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: lfs: true - - name: Enable pnpm via corepack - run: corepack enable - - - name: Set up Node.js 24 with pnpm cache - uses: actions/setup-node@v5 - with: - node-version: '24' - cache: pnpm + - name: Set up Node.js 24 and pnpm + uses: ./.github/actions/setup-node-pnpm - name: Install dependencies (frozen lockfile) run: pnpm install --frozen-lockfile @@ -122,26 +110,20 @@ jobs: e2e-tools: name: End-to-end (provisioned) runs-on: ubuntu-latest - # Not on pull requests: it downloads a 488 MB model on a cache miss. + # Not on pull requests: it downloads a 574 MB model on a cache miss. if: github.event_name == 'push' timeout-minutes: 45 steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # The audio fixtures are LFS-tracked; without this the suite # transcribes pointer files. lfs: true - - name: Enable pnpm via corepack - run: corepack enable - - - name: Set up Node.js 24 with pnpm cache - uses: actions/setup-node@v5 - with: - node-version: '24' - cache: pnpm + - name: Set up Node.js 24 and pnpm + uses: ./.github/actions/setup-node-pnpm - name: Install dependencies (frozen lockfile) run: pnpm install --frozen-lockfile @@ -149,20 +131,89 @@ jobs: - name: Cache the whisper models # Keyed on the catalogue, which holds the pinned file names, sizes and # URLs: a model change invalidates the cache, and nothing else does. - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.local/share/ailoud/models key: ailoud-models-${{ runner.os }}-${{ hashFiles('packages/core/src/provision/catalogue.ts') }} + - name: Dependency age (advisory) + # Reports, never blocks. Dependabot's version updates already respect + # the same 14 days through `cooldown` in .github/dependabot.yml, so the + # two do not argue -- but a Dependabot SECURITY update deliberately + # ignores cooldown, and a fresh version that closes an advisory is the + # one case where taking it beats waiting. Failing the build there would + # make the rule an obstacle to the thing it exists to protect. + # + # Not run on tags: ci.yml does not trigger on them, and the release + # path asks the question through the check-dependencies skill instead. + run: | + if ! node scripts/check-dependency-age.mjs; then + echo "::warning::A direct dependency is younger than 14 days, or its age could not be" + echo "::warning::checked. If it closes a critical advisory, record it in" + echo "::warning::scripts/dependency-age-exceptions.json with the advisory ID; otherwise" + echo "::warning::pin the previous version. See .agents/skills/check-dependencies." + fi + - name: Build run: pnpm build - - name: Provision ffmpeg, whisper.cpp and the models - # The same command a user runs, which makes this a test of `setup` as - # well as a prerequisite for the suite. --llm skip because no spec here - # summarises, and it would otherwise download another 2.1 GB. + - name: Install ffmpeg + # Installed with apt rather than left to `setup`, which correctly + # refuses to run `sudo apt-get` with no terminal to answer a password + # prompt on -- it reports the exact command instead of hanging. There + # is no terminal on a runner, so the sudo step is ours to do. + run: sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg + + - name: Provision whisper.cpp and the models + # Everything that needs no sudo: the whisper release and the model + # files. Still the command a user runs, so this remains a test of + # `setup` and not only a prerequisite for the suite. --llm skip because + # no spec here summarises, and it would otherwise fetch another 2.1 GB. + # + # This step is what proves the shipped default downloads and installs. + # The suite below then transcribes with `small` instead -- see its own + # comment for why -- so the two questions are answered separately. run: node apps/cli/dist/bin/ailoud.js setup --yes --llm skip + - name: Add the model the transcribing specs use + # `small` on top of the default. Naming a model is the documented way + # to switch, so this also exercises that path, and `setup` never + # deletes the previous file, so both end up cached together. + run: node apps/cli/dist/bin/ailoud.js setup --model small --yes --llm skip + + - name: Put the provisioned binaries on PATH + # The e2e sandbox writes its own config naming only the MODEL, and + # leaves `binary` at its default -- which means whisper-cli has to be + # found on PATH. `setup` installs it under the data directory and + # records the absolute path in the user's config instead, so doctor was + # entirely green while every transcribe spec exited 3. + # + # The directories are read back out of that config rather than spelled + # here, so bumping a pinned release does not silently break this. + run: | + set -euo pipefail + read_dir() { + node -e " + const { parseConfig } = require('./apps/cli/dist/config.js'); + const { readFileSync } = require('node:fs'); + const { homedir } = require('node:os'); + const { dirname } = require('node:path'); + const config = parseConfig( + readFileSync(homedir() + '/.config/ailoud/config.yaml', 'utf8'), + ); + const value = $1; + if (value && value.includes('/')) console.log(dirname(value)); + " + } + for dir in \ + "$(read_dir 'config.stt.whisperCpp.binary')" \ + "$(read_dir 'config.stt.diarization.binary')"; do + if [ -n "$dir" ]; then + echo "adding $dir to PATH" + echo "$dir" >> "$GITHUB_PATH" + fi + done + - name: Report what the machine now has # Printed whether or not the suite passes: a red e2e run is far quicker # to read next to doctor's account of what was actually installed. @@ -170,7 +221,23 @@ jobs: run: node apps/cli/dist/bin/ailoud.js doctor || true - name: End-to-end suite - run: pnpm test:e2e + # Transcribing specs use `small`, not the shipped default. They test + # the pipeline, not model quality, and on these four cores the default + # is about five times slower: it took this job from under five minutes + # to nineteen, and one spec's own wait ran out. Provisioning the real + # default is verified by the step above. + # + # Set in the command, not in `env:`, because the path needs $HOME and + # a step-level `env:` cannot expand it. It matches the directory this + # workflow already caches. + run: | + set -euo pipefail + export AILOUD_E2E_MODEL="$HOME/.local/share/ailoud/models/ggml-small.bin" + test -s "$AILOUD_E2E_MODEL" || { + echo "::error::$AILOUD_E2E_MODEL is missing; the provisioning step above should have written it" + exit 1 + } + pnpm test:e2e docs: name: Docs build (strict) @@ -178,7 +245,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Install uv uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c14bbe5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +# CodeQL, GitHub's static analysis, over the TypeScript in this workspace. +# +# Committed rather than enabled through the repository's default setup, for the +# same reason every other check here is a file: what runs, when, and over what +# should be reviewable in a diff. +# +# The weekly run matters as much as the per-push one -- most findings arrive +# because the queries improved, not because the code changed. + +name: CodeQL + +on: + push: + branches: [main, develop] + pull_request: + schedule: + # Monday morning, after Dependabot has opened whatever it is going to. + - cron: '30 6 * * 1' + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze TypeScript + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + # security-events: write is how the results reach the Security tab; a run + # without it analyses the code and then throws the answer away. + security-events: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: javascript-typescript + # The query set and the one rule this project excludes live in the + # config file, where each can carry its reason. security-and-quality + # over the default security-extended: this is a small codebase where + # the quality queries are worth reading rather than noise to filter. + config-file: .github/codeql/codeql-config.yml + + - name: Analyze + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2b17b60..03d7856 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,12 +29,13 @@ name: Docs on: - push: - tags: - # Final releases only: the negative pattern excludes any pre-release tag - # (a `-` qualifier), so an RC never starts this workflow at all. - - 'v*' - - '!v*-*' + # After a successful publish, not beside it. On the tag push both workflows + # started at once, so a publish that then refused -- or failed halfway + # through its three packages -- left the site advertising a version npm did + # not have. `workflow_run` waits for the answer. + workflow_run: + workflows: [Publish] + types: [completed] workflow_dispatch: # mike pushes commits to the gh-pages branch. @@ -50,11 +51,22 @@ jobs: deploy: name: Build and deploy versioned docs runs-on: ubuntu-latest + timeout-minutes: 20 + # A publish that failed publishes no documentation. Whether the commit is a + # final release is decided in the job, from the manifest in the commit + # itself -- `head_branch` on a workflow_run is documented as a branch, and + # a release must not rest on what it happens to hold for a tag. + if: >- + ${{ github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' }} steps: - - name: Checkout repository - uses: actions/checkout@v5 + - name: Checkout the commit that was published + uses: actions/checkout@v7 with: + # The SHA, which is unambiguous. Without a ref this would check out + # the default branch, which is not necessarily what was published. + ref: ${{ github.event.workflow_run.head_sha || github.ref }} # mike needs full history plus the gh-pages branch it maintains. fetch-depth: 0 @@ -73,15 +85,13 @@ jobs: run: | set -euo pipefail - if [ "${GITHUB_REF_TYPE}" != "tag" ]; then - echo "::error::Docs publish only from a release tag; ${GITHUB_REF_NAME} is not one." - exit 1 - fi - - version="${GITHUB_REF_NAME#v}" - # The tag filter already keeps pre-releases out, but - # workflow_dispatch can be pointed at any ref -- so refuse here too - # rather than trusting the trigger alone. + # The version of the thing that was published, from the commit that + # was published. publish.yml refuses a tag that disagrees with any + # manifest, so this and the tag are the same number by then. + version="$(node -p "require('./package.json').version")" + # publish.yml runs for pre-release tags too, so this workflow starts + # for them and stops here. A pre-release must not move the site: the + # docs describe what `npm install ailoud` gives you. case "${version}" in *-*) echo "${version} is a pre-release; skipping docs publication." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 226ccf9..354160f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,8 +43,12 @@ on: # id-token is what makes trusted publishing work; without it npm has no OIDC # token to exchange and falls back to asking for a credential that is not there. permissions: - contents: read + # contents: write is for creating the GitHub release. + contents: write id-token: write + # Reading code scanning results, to refuse a release that ships a finding + # nobody has looked at. + security-events: read concurrency: group: npm-publish @@ -56,22 +60,29 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 + env: + # The tag being released, resolved once. `github.ref_name` is the tag on + # a tag push but the BRANCH on a workflow_dispatch, so a step that let it + # default -- the changelog check did -- looked for a "## Version main" + # section and failed a re-run of a perfectly good tag. + TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.event.inputs.tag || github.ref }} lfs: true - - name: Enable pnpm via corepack - run: corepack enable - - - name: Set up Node.js 24 with pnpm cache - uses: actions/setup-node@v5 - with: - node-version: '24' - cache: pnpm - registry-url: 'https://registry.npmjs.org' + - name: Set up Node.js 24 and pnpm + # Deliberately WITHOUT setup-node's registry-url. Given one, it writes + # an .npmrc containing `_authToken=${NODE_AUTH_TOKEN}`; with no token + # secret to fill it, npm still saw credentials, sent an empty one, and + # never attempted the OIDC exchange -- the registry answered 404 on the + # PUT and the log said nothing about trusted publishing at all. With no + # .npmrc, npm finds it has no credentials and does the exchange. The + # default registry is registry.npmjs.org either way. + uses: ./.github/actions/setup-node-pnpm - name: Require an npm that can do trusted publishing # Trusted publishing landed in npm 11.5.1. An older npm fails by asking @@ -87,15 +98,103 @@ jobs: fi echo "npm $have" + - name: Refuse a final release while NPM_TOKEN is set + # First, not last. This used to live inside the publish step, after + # pnpm install, three checks, apt-get, the whole gate and the + # end-to-end suite -- around twenty minutes to learn a fact known at + # second zero. Nothing was ever published by it, so the cost was only + # time, but the documented order said otherwise and this is the most + # likely way a first production release fails. + # + # The bootstrap is over the moment the packages exist, and a token that + # keeps working is a token nobody gets round to removing. + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + version="${TAG#v}" + if [ -z "${NPM_TOKEN:-}" ]; then + echo "no NPM_TOKEN secret; this run will use trusted publishing" + exit 0 + fi + case "$version" in + *-*) + echo "::warning::publishing $version with the NPM_TOKEN secret; attach the trusted publisher and delete the secret" + ;; + *) + echo "::error::$version is a final release and NPM_TOKEN is set." + echo "::error::Attach the trusted publisher on all three package pages" + echo "::error::(organization lorem-dev, repository ailoud, workflow publish.yml," + echo "::error::environment empty), then delete the NPM_TOKEN secret and re-run." + exit 1 + ;; + esac + - name: Install dependencies (frozen lockfile) run: pnpm install --frozen-lockfile + - name: Check the changelog is fit to release + # First, and before the gate: a version number can never be reused and + # the unpublish window is 72 hours, so every reason to refuse should be + # found while refusing is still free. This checks the section exists and + # has entries, that it is inside the hard limit, that nothing is + # stranded under Development, and -- for a final tag -- that its + # pre-release sections were folded. + # + # The scripts' own tests run in ci.yml, on branches and pull requests + # rather than on tags, so a broken script is caught before a release + # depends on it. + run: node scripts/check-changelog.mjs "$TAG" + + - name: Refuse a release carrying unreviewed high-severity findings + # The dependency side of this is already enforced -- a --prod advisory + # or a version younger than 14 days blocks a release -- while what + # CodeQL finds in our own code did not reach here at all. This closes + # that asymmetry. + # + # Only `state=open` counts. A finding that was reviewed and explained + # is `dismissed` with its reason attached, and does not block; one + # nobody has looked at does. Fixing it and dismissing it are both + # answers -- ignoring it is not. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # --paginate, because an alert past the first hundred blocked nothing. + # jq -s 'add' folds the pages into one array. + if ! alerts=$(gh api --paginate \ + "repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100" \ + | jq -s 'add // [] | [.[] | select(.rule.security_severity_level == "high" or .rule.security_severity_level == "critical")]'); then + echo "::error::could not read code scanning results. A release that cannot check" + echo "::error::whether it ships a known finding is not one to make blind; fix the" + echo "::error::access or the analysis, then re-run." + exit 1 + fi + count=$(jq 'length' <<< "$alerts" || true) + # An empty or malformed answer used to pass: `jq length` printed + # nothing, `[ "" -gt 0 ]` errored, and a failing `if` condition is not + # caught by `set -e`, so the else branch reported "no alerts". + case "$count" in + '' | *[!0-9]*) + echo "::error::could not count the code scanning alerts (got '${count}')." + echo "::error::Refusing rather than assuming there are none." + exit 1 + ;; + esac + if [ "$count" -gt 0 ]; then + jq -r '.[] | "::error::\(.rule.security_severity_level) \(.rule.id) at \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' <<< "$alerts" + echo "::error::${count} open high or critical code scanning alert(s). Fix them, or" + echo "::error::dismiss each with a reason, then re-run this workflow on the tag." + exit 1 + fi + echo "no open high or critical code scanning alerts" + - name: Check that the tag matches the version in the manifests # A tag that disagrees with package.json publishes a version nobody # asked for, under a tag that points at different code. run: | set -euo pipefail - tag="${{ github.event.inputs.tag || github.ref_name }}" + tag="$TAG" want="${tag#v}" for manifest in packages/core packages/providers apps/cli; do have=$(node -p "require('./$manifest/package.json').version") @@ -106,6 +205,13 @@ jobs: done echo "all three manifests are at $want" + - name: Install ffmpeg + # The gate includes packages/providers/src/audio/ffmpeg.test.ts, which + # spawns the real binary. CI installs it for the same reason; this job + # runs the same gate, so it needs the same tool. Without it the release + # failed here rather than on anything about the release. + run: sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg + - name: Run the gate # Nothing is published that has not built, linted, typechecked and # passed its tests on this runner. An unpublish window is 72 hours and @@ -130,28 +236,134 @@ jobs: done ls -l dist-npm + - name: Check each tarball carries the licence and no source + # pnpm copies the repository's LICENSE into every workspace tarball, + # which is why no package directory holds its own copy -- three copies + # of a 224-line file would drift. That makes it an invariant worth + # checking rather than assuming: if pnpm ever stops, this fails the + # release instead of publishing an unlicensed package. + # + # The same step guards the other packing mistake: shipping `src` and + # the tests. Before `files` was set, the CLI tarball carried 82 source + # files and 32 test files. + run: | + set -euo pipefail + for tarball in dist-npm/*.tgz; do + echo "--- $tarball" + contents=$(tar -tzf "$tarball") + if ! grep -qiE '^package/LICEN[SC]E' <<< "$contents"; then + echo "::error::$tarball has no LICENSE." + exit 1 + fi + if grep -qE '^package/src/' <<< "$contents"; then + echo "::error::$tarball ships src/; check the \`files\` field." + exit 1 + fi + if grep -qE '\.test\.' <<< "$contents"; then + echo "::error::$tarball ships tests; check the \`files\` field." + exit 1 + fi + # 1.0.0-dev.1 went out with no README, so all three npm pages read + # "This package does not have a README" -- the first thing anyone + # arriving from a search sees. The libraries hold their own; the + # CLI's is the repository's, copied in by its prepack script, which + # is the part that could silently stop working. + if ! grep -qiE '^package/README' <<< "$contents"; then + echo "::error::$tarball has no README." + exit 1 + fi + echo "licence, readme, no source, no tests" + done + - name: Publish, dependencies first # Order matters: the CLI's manifest names exact versions of the two # libraries, so they have to exist first. --provenance records which # workflow and which commit produced each tarball, verifiable from the - # package page. + # package page -- it works under either credential below. + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail - tag="${{ github.event.inputs.tag || github.ref_name }}" + tag="$TAG" version="${tag#v}" - # A pre-release goes out under `next`, so `npm install ailoud` keeps - # returning the last stable version until a final tag moves `latest`. + # BOOTSTRAP ONLY. Trusted publishing is configured per package on + # npmjs.com, which cannot be done for a package that does not exist + # yet -- so the first version of each of the three has to go out on a + # token, and npm answers ENEEDAUTH without one however complete the + # OIDC setup is. Once the trusted publisher is attached to all three + # package pages, delete the NPM_TOKEN secret: with it gone this + # branch is skipped and npm does the OIDC exchange instead, which is + # the arrangement with nothing to expire. + # Whether a token is allowed here at all was settled in the first + # step of this job; this only applies it. + if [ -n "${NPM_TOKEN:-}" ]; then + npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN" + fi + # Three kinds of tag, three destinations. See the dev-tag skill. + # -dev.N a snapshot to try -> dev + # -rc.N what the release will be -> next + # final the release -> latest + # Only the last moves `latest`, so `npm install ailoud` never picks up + # a pre-release by accident -- with one exception nothing here can + # prevent: npm sets `latest` on a package's first publish whatever + # --tag says, and `latest` can be moved but never removed. case "$version" in - *-*) dist_tag=next ;; - *) dist_tag=latest ;; + *-dev.*) dist_tag=dev ;; + *-*) dist_tag=next ;; + *) dist_tag=latest ;; esac + # Everything checkable, checked before the first publish. The loop + # goes library, library, CLI, and a version number npm has seen can + # never be reused -- so a fault found on the third iteration leaves + # two packages published at a version the release can no longer use. + for name in ailoud-core ailoud-providers ailoud; do + # An ABSOLUTE path. Given `dist-npm/ailoud-core-1.0.0-dev.1.tgz`, + # npm read the slash as the `owner/repo` GitHub shorthand and tried + # to `git ls-remote ssh://git@github.com/dist-npm/...git`, which + # failed on a missing public key rather than on anything to do with + # publishing. + if [ ! -f "$PWD/dist-npm/${name}-${version}.tgz" ]; then + echo "::error::dist-npm/${name}-${version}.tgz was not packed." + exit 1 + fi + done + node scripts/preflight-npm-auth.mjs + echo "publishing $version under dist-tag $dist_tag" for name in ailoud-core ailoud-providers ailoud; do - tarball=$(ls dist-npm/${name}-${version}.tgz) + tarball="$PWD/dist-npm/${name}-${version}.tgz" echo "--- $tarball" npm publish "$tarball" --provenance --access public --tag "$dist_tag" done + - name: Create the GitHub release from CHANGES.md + # After the publish, so a release object never advertises a version the + # registry refused. `gh release create` rather than a third-party + # action: adding one to the release path would sit oddly beside a + # 14-day rule for everything else we depend on. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="${TAG#v}" + # Writes RELEASE_NOTES.md from the `## Version ` section, + # and refuses if that section is missing, empty or over the limit. + node scripts/release-notes.mjs "$TAG" + prerelease="" + case "$version" in + *-*) prerelease="--prerelease" ;; + esac + if gh release view "$TAG" >/dev/null 2>&1; then + echo "release $TAG already exists; updating its notes" + gh release edit "$TAG" --notes-file RELEASE_NOTES.md + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file RELEASE_NOTES.md \ + --verify-tag \ + $prerelease + fi + - name: Report what was published if: always() run: ls -l dist-npm || true diff --git a/.gitignore b/.gitignore index 6c04168..f144abe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ coverage/ .DS_Store .idea/ *.swp +# Scratch: benchmark corpora, model files, measurement output. Nothing here is +# an input to the build, and some of it runs to gigabytes. +tmp/ # `.claude/*`, not `.claude/`: git cannot re-include a file whose parent # directory is excluded by a pattern ending in a slash, so the negation below # was silently doing nothing. @@ -22,7 +25,18 @@ TODO*.md .venv/ site/ docs/superpowers/**/scratch/ +# Specs and plans belong in .superpowers/ (already ignored above), never here. +# Listed anyway so a stray one written to the wrong path cannot be staged -- +# see "Specs and Plans" in AGENTS.md. +.agents/specs/ +.agents/plans/ +docs/superpowers/specs/ +docs/superpowers/plans/ # generated by scripts/release-notes.mjs at release time RELEASE_NOTES.md # npm tarballs built by the publish workflow dist-npm/ + +# Copied from the repository root by apps/cli prepack, so the npm page for +# `ailoud` shows the real README without a second copy in git. +apps/cli/README.md diff --git a/AGENTS.md b/AGENTS.md index 9652edd..4a89552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,12 +18,27 @@ resulting text. No microphone or system-audio capture; import only. The CLI is the only front end -- there is no GUI, and the interface is English-only. The multilingual part of this project is the audio, not the UI. -M1, the current milestone, ships `import`, `transcribe`, `ls`, `show`, and -`doctor`. The rest of the CLI surface (`search`, `collection`, `tag`, -`summarize`, `export`, `config`) belongs to later milestones and does not -exist yet; do not document or assume commands beyond what M1 lists. The full -design lives in the maintainer's planning notes under `.superpowers/`, which -is not tracked in git, so treat this file as the authority on what exists. +The commands, grouped by the noun they act on: + +| Command | Does | +| ---------------------------------------------------------- | ----------------------------------------- | +| `audio import\|transcribe\|annotate\|search\|ls\|show\|rm` | the library and everything over it | +| `audio summarize` | writes a summary and saves it as a report | +| `report ls\|show\|rm` | saved reports | +| `job ls\|show\|rm` | background jobs started with `--detach` | +| `template ls\|new` | what shape a summary of a kind takes | +| `mcp` and `mcp install\|uninstall\|update` | serve the library to an agent | +| `doctor`, `setup` | check and provision the machine | +| `self check\|update\|sync` | this installation of ailoud | + +The verbs also answer at the top level (`ailoud search`, `ailoud transcribe`), +and each second-level verb has a one-letter alias. + +Do not trust a list of commands in prose over the binary. This section was +wrong for a while -- it described an early milestone and said `search` and +`summarize` "do not exist yet" long after both shipped -- so check with +`node apps/cli/dist/bin/ailoud.js --help` after a build, which cannot be +stale. --- @@ -175,6 +190,13 @@ matching the `skillkeeper` repository this project inherits its conventions from. They are working notes for driving an implementation, not a published record. +**Never commit one, and never put one anywhere else.** Not under `.agents/`, +not under `docs/`, not beside the code it describes. A spec in the history is +a second description of the software that stops being true the moment the +code moves on, and a reader who finds it has no way to tell it is stale. If +a skill or a habit tells you to write a plan to some other path, `.superpowers/` +wins. + The consequence is that a fresh clone carries no design document. Anything a contributor must know to work here belongs in this file, in README.md, or in CONTRIBUTING.md -- not in a plan only the maintainer has. @@ -205,7 +227,7 @@ whole transcripts into its context. A project keeps its own library in `.ailoud/`, found by walking up from the working directory the way git finds `.git`. The config stays per-user: it names installed binaries and model files, and making it local would mean -re-downloading a 488 MB model per repository. +re-downloading a 574 MB model per repository. --- @@ -224,13 +246,13 @@ Python tooling here is driven by `uv`, never `pip` or a hand-rolled venv. ### Structure -Four sections, and new pages belong in one of them: +Four sections, in nav order, and new pages belong in one of them: | Section | Holds | | --------------- | ------------------------------------------------ | | Getting Started | install, set up, first transcript, first summary | -| Usage | one page per thing you do with the CLI | | MCP | configuring and using the MCP server | +| Usage | one page per thing you do with the CLI | | Development | architecture, the gate, releasing | Every page added to `docs/` must appear in `nav:` in `mkdocs.yml`, or the @@ -257,6 +279,203 @@ Run the `check-docs` skill after changing any command or option. --- +## Branches and Tags + +| Branch | Is | +| --------- | --------------------------------------- | +| `main` | the release branch | +| `develop` | integration; feature branches land here | + +**While the project is pre-release, work happens directly on `main`** and +`develop` follows it: a push to `main` opens a back-merge PR +(`.github/workflows/backmerge.yml`). Once releases start that inverts -- +features land on `develop`, only release commits reach `main` -- and the same +workflow keeps `develop` from falling behind either way. Both branches are +protected against deletion and force-push with CI required; an admin can still +push directly, which is what makes the pre-release flow possible. + +The three required checks are the ones that run on a pull request. The +provisioned end-to-end job is deliberately NOT required: it runs only on push, +so requiring it would leave every PR waiting for a check that never arrives. + +| Tag | Cut from | npm dist-tag | Docs | Retires snapshots | +| -------------- | ---------- | ------------ | ---- | ----------------- | +| `v1.2.3-dev.1` | any branch | `dev` | no | no | +| `v1.2.3-rc.1` | `develop` | `next` | no | no | +| `v1.2.3` | `main` | `latest` | yes | yes | + +Only a final tag moves `latest`, publishes the site and retires what it +supersedes. `publish.yml` refuses a tag that disagrees with any manifest +version. + +Use the `dev-tag` skill for a snapshot; `bump-version` then `pre-release-check` +for a release. + +Cutting a final tag folds its pre-release sections back into one. The order +matters, and it is the opposite of what reads naturally: + +``` +node scripts/bump-version.mjs 1.0.0 # FIRST: promotes Development +node scripts/fold-prereleases.mjs 1.0.0 # then merges the 1.0.0-dev.* sections in +node scripts/check-changelog.mjs v1.0.0 # refuses if anything is left over +``` + +`bump-version` promotes `## Development` into a `## Version ` section, so +folding first leaves it a second, empty one to create -- two `## Version 1.0.0` +headings, and `check-changelog` then fails on the empty one with "has no +entries". Tested both ways in a sandbox before the first release. + +`publish.yml` runs that check on every tag before it builds anything. The +limits live in `scripts/lib/changelog.mjs` and are quoted, not restated, +everywhere else -- a limit that differs between the script that warns and the +script that refuses is worse than no limit. The scripts have tests +(`scripts/**/*.test.mjs`), which run in CI on branches and pull requests but +not on tags. + +--- + +## Publishing + +Nothing long-lived is stored. Publishing authenticates by exchanging the CI +job's OIDC identity for a short-lived, per-package npm token. Retiring the +snapshots a release supersedes does NOT -- see below; it is a manual step. + +### How the exchange works + +`npm publish` does this for itself, and `scripts/lib/npmOidc.mjs` makes the +same two calls (read out of npm's `lib/utils/oidc.js`) so that +`preflight-npm-auth.mjs` can establish before publishing that npm will accept +every package: + +``` +GET $ACTIONS_ID_TOKEN_REQUEST_URL&audience=npm:registry.npmjs.org + Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN -> { value } +POST /-/npm/v1/oidc/token/exchange/package/ + Authorization: Bearer -> { token } +``` + +Three consequences worth knowing before changing any of this: + +- **`id-token: write` is required**, or GitHub mints no identity and the first + call is skipped entirely. +- **The token it returns authenticates `npm publish` and nothing else.** It is + publish-scoped and spent: `npm deprecate` with it answered `E404 ... or you +do not have permission`, then `E401 ... token is invalid` on every call + after. Measured on the 1.0.0 release. So the exchange can answer "will npm + accept us", and cannot be used to do anything but publish. +- **A trusted publisher is bound to a workflow FILE.** Entering through a + reusable workflow leaves `workflow_ref` pointing at the caller and + `job_workflow_ref` at the callee; npm matches the caller, which the 1.0.0 + release also confirmed. +- **The token never reaches a command line or a log.** It goes into a + temporary 0600 `.npmrc` removed in a `finally`; `withNpmToken` is tested + both for the normal path and for a body that throws. The id token's claims + ARE logged -- `sub`, `workflow_ref`, `job_workflow_ref` -- because a + rejection is otherwise indistinguishable from a missing package. + +### The bootstrap exception + +A trusted publisher is attached to a package that already exists, so the FIRST +version of each package cannot use one: npm answers `ENEEDAUTH` however +complete the setup is. A token in the `NPM_TOKEN` secret introduces the +packages to the registry, and deleting the secret is the whole of the switch -- +`publish.yml` uses it when present and OIDC when not. + +The rule is enforced rather than remembered, because a token that keeps working +is a token nobody removes: + +| Tag | `NPM_TOKEN` set | Result | +| ----------- | --------------- | -------------------------- | +| pre-release | yes | publishes, logs a warning | +| final | yes | **refuses**, names the fix | +| any | no | OIDC exchange | + +### What blocks a release + +Enforced by `publish.yml`, all of it before anything is built or published: + +| Refuses when | Why | +| ---------------------------------------------- | ------------------------------------------- | +| the changelog is unfit (`check-changelog.mjs`) | a release nobody can read the notes for | +| an open high or critical code scanning alert | a finding nobody has looked at, shipped | +| a manifest disagrees with the tag | publishes a version nobody asked for | +| `NPM_TOKEN` is set on a final tag | a stored credential outliving its bootstrap | +| the gate or the end-to-end suite fails | the ordinary reasons | +| a tarball lacks its LICENCE or README | what 1.0.0-dev.1 shipped | + +A code scanning finding counts only while it is `open`. One reviewed and +explained is `dismissed` with its reason attached and does not block; fixing +and dismissing are both answers, ignoring is not. The dependency side of the +same question is the `check-dependencies` skill, which a human runs before +tagging. + +### Retiring superseded snapshots + +Only a production release retires anything, and it happens after the publish +succeeds -- deprecating first would, if the publish then failed, leave every +`-dev.N` pointing at a release that does not exist while `dev` is the only +installable thing. + +``` +pnpm retire 1.0.0 # prints the plan, changes nothing +NPM_TOKEN=npm_... pnpm retire 1.0.0 --yes # carries it out +``` + +**Do this after every final release, by hand.** Nothing prompts for it: the +release workflow cannot, and a release that is otherwise complete looks +finished. Until it runs, the superseded snapshots stay undeprecated and `@dev` +still resolves to the last one -- someone installing `ailoud@dev` gets an older +build than `ailoud`. + +`NPM_TOKEN` is **required** for `--yes`, not merely preferred. Falling back on +the ambient `npm login` sounds accommodating and is not: with 2FA on writes -- +the default -- npm asks for a one-time code on every write, and this makes +twelve of them, and without a usable credential it drops into an interactive +web login and waits, so the script looks hung. Refusing up front is the +difference between one authentication and twelve prompts. + +Use a granular access token with read-and-write on the three packages. It goes +into a temporary 0600 npmrc and never onto a command line. The plan (no +`--yes`) needs no credential at all. + +This is a manual step, run under `npm login`. Automating it was tried and +removed: the OIDC token cannot deprecate (above). If the npm side does not +complete, the script leaves the tags alone and exits non-zero -- the tags are +what name which pre-releases to retire, so deleting them after a failed +deprecation would destroy the only record of what was missed. + +It deprecates every `1.0.0-dev.*` of all three packages, moves the `dev` +dist-tag onto the release, and deletes the tags. Which versions exist comes +from the registry, not from the git tags: the tags used to be the list, which +made them the only record of what still needed retiring, and a tag is a thing +that gets deleted. Three things it deliberately does not do: + +- **Unpublish.** Allowed for 72 hours only, the version number can never be + reused after, and anyone who pinned it has their install broken. A deprecated + version keeps working and prints a notice. +- **Delete a tag whose commit is not on `origin/main`.** The published + provenance attests that commit; unreachable, it can be collected, leaving the + attestation pointing at nothing. Those tags are reported and kept. +- **Remove the `dev` dist-tag.** `npm dist-tag rm` makes `install @dev` + fail outright for anyone who uses it. It is pointed at the release instead, + so `@dev` keeps working and never hands out something older than `latest`. + +### npm facts that constrain all of the above + +None of these can be worked around, so design around them: + +- **A version number is used up forever.** `1.0.0-dev.1` cannot be republished + even after an unpublish. A botched snapshot needs a new number, not a retry. +- **`latest` is set on a package's first publish** whatever `--tag` says, and + `latest` can be moved but never removed. A package introduced by a + pre-release answers `npm install ` with it until a final version + exists. +- **`npm publish` is the only thing trusted publishing authenticates.** + `deprecate` and `dist-tag` need a real credential, which in practice means a + human at a terminal. + +--- + ## The Changelog `CHANGES.md` is for someone deciding whether to upgrade. It is not a commit @@ -319,7 +538,7 @@ about to add an entry will actually see them. ## Local Development Skills -Seven skills live under `.agents/skills/`. Invoke them when the situation +Ten skills live under `.agents/skills/`. Invoke them when the situation calls for it: | Skill | When to use | @@ -330,7 +549,10 @@ calls for it: | `check-licenses` | After editing any `package.json` -- verify all npm dependencies are license-compliant and update LICENSE. | | `run-tests-and-linters` | Before marking any task done -- run the full gate (build, format check, lint, typecheck, test:cov at 90%). | | `check-fixtures` | After touching import, transcribe, or the audio/STT providers -- drive the built binary against `fixtures/` end to end, in a throwaway `HOME`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME`, and confirm the working tree stays clean. | +| `check-dependencies` | Before every release and after any dependency change -- audit for advisories, report funding, and update what is behind, refusing any version published less than 14 days ago unless it fixes a critical advisory. | | `pre-release-check` | Before cutting a release -- runs the `check-*` and `run-tests-and-linters` skills above (not `bump-version`) plus version-bump and commit-format checks. | +| `dev-tag` | To publish a snapshot to npm under the `dev` dist-tag without promising a release -- cuts a `v-dev.` tag. | +| `check-migrations` | After touching `packages/core/src/db/schema.ts` -- run the migration lock and snapshot tests, and judge from git history whether an edited migration has already shipped (which the tests cannot see). | --- diff --git a/CHANGES.md b/CHANGES.md index b43f25e..ea82669 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -40,6 +40,80 @@ ## Development +## Version 1.2.0 + +### Added + +- `mcp install` can add `ailoud` to an agent's command allow-list, so the agent + runs it without asking each time. `--allow-shell` answers without a prompt. +- `ailoud setup --force` reinstalls everything ailoud needs, even when every + check already passes, useful for a corrupted install. +- `self completions` installs, uninstalls, updates and prints completions for + bash, zsh and fish. `setup` offers to install them after a successful run + (skip with `--no-completions`), and `self sync` keeps installed ones current. +- `transcribe` and `summarize` take `--detach`, running in the background and + printing a job id; `job ls|show|rm` follow them. Both report an approximate + percentage while they run, on the spinner and in the job's state file. +- `--max-cpu` and `resources.maxCpuPercent` cap how much of the machine each + engine takes; `--no-gpu` opts out (transcribe only). Segmentation and + diarization get a lower, measured share, and `doctor` reports the CPU split, + the GPU backends each binary loaded, and the thread counts derived from them. +- `--denoise on` cleans audio before transcription, and `auto` cleans only + what measures as noisy. Both are off by default: benchmarked over six + corpora, denoising never improved a transcript and sometimes cost accuracy. + +### Changed + +- `setup` installs `large-v3-turbo-q5_0` (574 MB) instead of `small`, and no + longer offers `medium` or the f16 `large-v3-turbo`, each of which is beaten + on accuracy, size and speed by a smaller model. `large-v3` (3.1 GB) is + offered for a hard recording worth it. Retired models still install when + named, and an installed model is never replaced without being asked for. + Without a GPU the new default can be several times slower than `small`, + depending on the machine; `--model small` goes back. +- `setup --model ` now switches the transcription model even on a + healthy machine, instead of being ignored, and prints where the previous + model file was left, since ailoud never deletes it. +- New rules blocks now go to `.claude/CLAUDE.md`; blocks already in a + project's own rules files stay there and are all kept current. +- The MCP `transcribe` and `summarize` tools return a job id and a `job_status` + tool to poll, instead of blocking until the work is done, and `transcribe` + refuses until the speaker count and expected languages are declared, + suggesting languages from the recording's name. A finished transcription + reports the speakers still to be named, so an agent offers to name them. + +### Fixed + +- A rules file holding a stray `AILOUD_END` marker is found again, instead of + gaining a duplicate block on every `mcp install` and never being refreshed. + +## Version 1.1.0 + +### Added + +- `ailoud self update` installs a newer version, then refreshes the agent + rules block in every project ailoud has been used in. +- `ailoud self check` reports whether a newer version exists without + installing it, and takes `--json`. +- `ailoud self sync` refreshes the agent rules block in every registered + project without updating anything. +- Other commands mention a newer version at most once a day. Turn it off with + `AILOUD_NO_UPDATE_CHECK=1` or `update.check: false`. + +### Changed + +- `ailoud report ls` exits 0 when there are no reports, matching `ls` on an + empty library and its own `--json` form. +- Command output now stays inside the terminal frame instead of printing + around it. + +### Fixed + +- `ailoud mcp install` and `mcp update` no longer empty a rules file when the + write fails part-way, such as on a full disk. + +## Version 1.0.0 + ### Added - `ailoud audio import` adds audio and video files, or whole directories, to a @@ -55,10 +129,10 @@ as a report. `--template` shapes it for the kind of conversation -- a 1:1, a performance review, an architecture discussion, a decision between solutions -- and `--context` supplies what the transcript does not say. Templates are - editable YAML files under the config directory. -- Four summarisation engines behind one setting: a local GGUF model through - llama.cpp, Claude by subscription through the Claude Code CLI, Claude by API, - and any OpenAI-compatible endpoint including Ollama and LM Studio. + editable YAML files under the config directory. Four engines sit behind one + setting: a local GGUF model through llama.cpp, Claude by subscription through + the Claude Code CLI, Claude by API, and any OpenAI-compatible endpoint, + including Ollama and LM Studio. - `ailoud report ls|show|rm` lists, prints and deletes saved reports. - `ailoud mcp` serves the library to an AI agent over MCP: sixteen tools, three prompts, and transcripts as addressable resources. Deleting takes two calls, diff --git a/CLAUDE.md b/CLAUDE.md index 305d1be..a7d9820 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +1,22 @@ # CLAUDE.md -- ailoud `ailoud` is a command-line tool that transcribes audio and video into a local -recording library, and, in a later milestone, will summarize and answer -questions over it through a large language model. Speech-to-text and the -LLM are separate engine layers behind stable ports. There is no GUI; the -CLI is the only front end, and the interface is English-only. +recording library, searches it, and summarizes and answers questions over it +through a large language model. Speech-to-text and the LLM are separate engine +layers behind stable ports. There is no GUI; the CLI is the only front end, and +the interface is English-only. + +**Never commit a design spec or an implementation plan.** They belong in +`.superpowers/`, which is git-ignored -- never in `.agents/`, never in +`docs/`, never in a commit. See "Specs and Plans" in AGENTS.md. This is +stated here because it is the rule most easily missed by reading only the +top of that file. **Must read before touching code:** - [AGENTS.md](./AGENTS.md) -- project overview, workspace layout, the dependency direction, running the gate, conventions, and local skills. + Read it in full, not just the opening sections. - [CONTRIBUTING.md](./CONTRIBUTING.md) -- commit rules, dependency license policy, GPG signing. diff --git a/README.md b/README.md index 1e9c411..88b73f7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@

AILoud

+ npm Documentation - License + License Coverage CI

@@ -14,21 +15,6 @@ summarises it. Speech-to-text runs on your machine; summaries can too. --- -## Overview - -One CLI (`ailoud`) over a local library: - -- **Transcribe** audio and video with whisper.cpp, including recordings that - switch between languages, and attribute lines to speakers. -- **Search** the whole library full-text and get back the matching lines with - timestamps, not whole transcripts. -- **Summarise** one recording or a tagged group into a saved report, shaped by - a template for the kind of conversation it was. -- **Serve** the library to an AI agent over - [MCP](https://lorem-dev.github.io/ailoud/latest/mcp/). - -Nothing leaves your machine unless you choose a hosted model for summaries. - ## Install Needs [Node.js](https://nodejs.org/) 24 or newer. @@ -37,82 +23,101 @@ Needs [Node.js](https://nodejs.org/) 24 or newer. npm install -g ailoud ``` -Then install the tools it drives -- ffmpeg, whisper.cpp, the models: +`setup` installs the tools it drives -- ffmpeg, whisper.cpp, the models -- +and `doctor` checks them: ```shell ailoud setup ailoud doctor ``` -See [Getting Started](https://lorem-dev.github.io/ailoud/latest/getting-started/). - ---- - -## CLI quick start +Nothing leaves your machine unless you choose a hosted model for summaries. -Import, transcribe, read: +## Update ```shell -ailoud audio import ~/Recordings --tag standup -ailoud audio transcribe -ailoud audio ls -ailoud audio show 01M1B2 +ailoud self update ``` -Find where something was said, without reading a transcript: +It checks the registry first, so there is nothing to run before it. A snapshot +moves only to a newer snapshot of the same version, or to a release. -```shell -ailoud audio search rollback -ailoud audio f "before sunrise" --tag standup -``` +--- -Summarise, with a shape and the context the transcript does not carry: +## Use it with an agent ```shell -ailoud audio summarize 01M1B2 --template one-on-one \ - --context "Ann is Ben's manager; this is their fortnightly." -ailoud report ls +ailoud mcp install ``` -Every verb has a one-letter alias, and the letter means the same in every -group -- `l` list, `v` view, `r` remove, `f` find: +It configures one or more agents, at project or global scope: + +| Agent | Scopes | +| ---------- | --------------- | +| `claude` | project, global | +| `codex` | project, global | +| `opencode` | project, global | +| `gemini` | project, global | +| `hermes` | global only | +| `copilot` | global only | + +It writes the MCP registration and a rules block the agent reads before its +first call. The agent then gets these tools: ```shell -ailoud audio l -ailoud report l +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ailoud mcp | jq -r '.result.tools[].name' ``` -Run `ailoud --help` or ` --help` for the full set, also in the -[CLI Reference](https://lorem-dev.github.io/ailoud/latest/usage/cli/). +``` +list_recordings +list_untagged +list_tags +search_transcripts +get_transcript +list_speakers +list_reports +get_report +list_templates +job_status +annotate +import_recording +transcribe +summarize +create_template +delete_recording +delete_report +``` ---- +Reading tools return matches and file paths, never a whole transcript in one +call; deleting needs a second call carrying a confirmation token. See +[MCP](https://lorem-dev.github.io/ailoud/latest/mcp/). -## Templates +--- -A template decides a summary's headings, because different conversations -divide differently: `one-on-one`, `performance-review`, -`architecture-planning`, `solution-decision`, `offsite`, `meeting`. +## The CLI -```shell -ailoud template ls -ailoud template new sprint-retro --from one-on-one -``` +| Command | Does | +| ---------------------------------------------------------- | ----------------------------------------- | +| `audio import\|transcribe\|annotate\|search\|ls\|show\|rm` | the library and everything over it | +| `audio summarize` | writes a summary and saves it as a report | +| `report ls\|show\|rm` | saved reports | +| `job ls\|show\|rm` | background jobs started with `--detach` | +| `template ls\|new` | what shape a summary of a kind takes | +| `mcp` and `mcp install\|uninstall\|update` | serve the library to an agent | +| `doctor`, `setup` | check and provision the machine | +| `self check\|update\|sync` | this installation of ailoud | -They are YAML files in `~/.config/ailoud/templates/`. Edit one and the change -takes effect; AILoud never overwrites a file you have edited. See -[Templates](https://lorem-dev.github.io/ailoud/latest/usage/templates/). +Every verb also works at the top level, and has a one-letter alias. Full +reference: [CLI Reference](https://lorem-dev.github.io/ailoud/latest/usage/cli/). --- -## MCP - -```json -{ "mcpServers": { "ailoud": { "command": "ailoud", "args": ["mcp"] } } } -``` +## Documentation -Sixteen tools over the same library the CLI uses. Deleting takes two calls: the -first describes what would go and returns a confirmation token, the second -carries it out. See [MCP](https://lorem-dev.github.io/ailoud/latest/mcp/). +- [Getting Started](https://lorem-dev.github.io/ailoud/latest/getting-started/) +- [Usage](https://lorem-dev.github.io/ailoud/latest/usage/recordings/) +- [MCP](https://lorem-dev.github.io/ailoud/latest/mcp/) +- [Development](https://lorem-dev.github.io/ailoud/latest/development/development/) --- @@ -125,14 +130,11 @@ A pnpm workspace: `packages/core` (domain and ports, no I/O), pnpm build && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:cov ``` -See the -[Development guide](https://lorem-dev.github.io/ailoud/latest/development/development/) -and [Architecture](https://lorem-dev.github.io/ailoud/latest/development/architecture/). Commit rules and the dependency licence policy are in -[CONTRIBUTING.md](./CONTRIBUTING.md). +[CONTRIBUTING.md](https://github.com/lorem-dev/ailoud/blob/main/CONTRIBUTING.md). --- ## License -Apache-2.0. See [LICENSE](./LICENSE). +Apache-2.0. See [LICENSE](https://github.com/lorem-dev/ailoud/blob/main/LICENSE). diff --git a/apps/cli/package.json b/apps/cli/package.json index ef55947..2b1f12a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "ailoud", - "version": "0.0.0", + "version": "1.2.0", "type": "module", "bin": { "ailoud": "./dist/bin/ailoud.js" @@ -18,10 +18,12 @@ "zod": "4.4.3" }, "scripts": { + "prepack": "node -e \"require('node:fs').copyFileSync('../../README.md','README.md')\"", "build": "tsc -b tsconfig.build.json && chmod +x dist/bin/ailoud.js", "typecheck": "tsc -p tsconfig.json --noEmit" }, "description": "Transcribe audio and video into a local library, then search and summarise it", + "author": "Lorem Dev ", "license": "Apache-2.0", "homepage": "https://lorem-dev.github.io/ailoud/", "bugs": { diff --git a/apps/cli/src/bin/ailoud.ts b/apps/cli/src/bin/ailoud.ts index ce2adff..81eaa1b 100644 --- a/apps/cli/src/bin/ailoud.ts +++ b/apps/cli/src/bin/ailoud.ts @@ -1,32 +1,80 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning +import { styleText } from 'node:util'; import { buildProgram, exitCodeFor, isCommanderError } from '../program.js'; import { createContext } from '../wiring.js'; - -async function main(): Promise { - const context = await createContext(process.env); - try { - await buildProgram(context).parseAsync(process.argv); - return 0; - } finally { - context.store.close(); - } -} +import { startUpdateCheck } from '../updateNotice.js'; +import type { UpdateCheck } from '../updateNotice.js'; +import { VERSION } from '../version.js'; function messageFor(error: unknown): string { return error instanceof Error ? error.message : String(error); } -main().then( - (code) => { - process.exitCode = code; - }, - (error: unknown) => { - const code = exitCodeFor(error); +async function main(): Promise { + // Started as early as possible -- right after the context exists -- and + // read only once the command has finished, below. Never awaited here: + // waiting on the network before the command's own work even starts would + // add up to `updateTimeoutMs` to every single run of, say, `ailoud ls`. + let notice: UpdateCheck | null = null; + let code: number; + try { + const context = await createContext(process.env); + notice = startUpdateCheck({ + fs: context.fs, + clock: context.clock, + userDataDir: context.paths.userDataDir, + currentVersion: VERSION, + argv: process.argv.slice(2), + env: process.env, + stderrIsTTY: process.stderr.isTTY === true, + checkEnabled: context.config.update.check, + // The port, not a second client. `VersionSource.published` takes the + // cancellation signal precisely so this caller can share the provider's + // implementation -- guards, escaping, empty-list refusal and all -- + // instead of the hand-rolled copy that used to live here and had + // drifted out of step with it. + published: (signal) => context.versionSource.published('ailoud', signal), + }); + try { + await buildProgram(context).parseAsync(process.argv); + code = 0; + } finally { + context.store.close(); + } + } catch (error) { + code = exitCodeFor(error); // commander already printed its own message for a usage failure, or // its help/version text, through configureOutput above. if (!isCommanderError(error)) { process.stderr.write(`ailoud: ${messageFor(error)}\n`); } - process.exitCode = code; - }, -); + } + + // Same place the process decides its exit code: printed only when the + // check has already settled (see `UpdateCheck.finish`'s own doc comment). + // Never printed when the context itself failed to build -- there is no + // `notice` at all in that case, so nothing to abort or to await. + if (notice !== null) { + const target = await notice.finish(); + if (target !== null) { + // Yellow: this is the one line ailoud prints that the user did not ask + // for, so it has to be distinguishable at a glance from the output they + // did. `styleText` is given the stream, so it emits nothing when stderr + // cannot take colour and it honours NO_COLOR -- belt and braces, since + // the notice is already suppressed when stderr is not a terminal. + process.stderr.write( + styleText( + 'yellow', + `ailoud: a newer version is available (${VERSION} -> ${target}). Run "ailoud self update" to install it.`, + { stream: process.stderr }, + ) + '\n', + ); + } + } + + return code; +} + +main().then((code) => { + process.exitCode = code; +}); diff --git a/apps/cli/src/commands/annotate.ts b/apps/cli/src/commands/annotate.ts index 1a54522..3ee2fa1 100644 --- a/apps/cli/src/commands/annotate.ts +++ b/apps/cli/src/commands/annotate.ts @@ -121,7 +121,7 @@ export function registerAnnotate(program: Command, context: CliContext): void { ? [] : [`${assignments.length} speaker name${assignments.length === 1 ? '' : 's'}`]), ]; - context.write(`${recording.id} set ${parts.join(', ')}`); + context.ui.success(`${recording.id} set ${parts.join(', ')}`); }); }); } diff --git a/apps/cli/src/commands/commands.test.ts b/apps/cli/src/commands/commands.test.ts index 6e93353..fe3de37 100644 --- a/apps/cli/src/commands/commands.test.ts +++ b/apps/cli/src/commands/commands.test.ts @@ -1,8 +1,34 @@ -import { describe, expect, it } from 'vitest'; -import { FailureError } from '@ailoud/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import { FailureError, UsageError } from '@ailoud/core'; +import type { Recording } from '@ailoud/core'; +import { FakeStt } from '@ailoud/core/testing'; +import type { FakeAudioTool } from '@ailoud/core/testing'; import { buildProgram } from '../program.js'; -import { context } from './testContext.js'; +import { context, withRealDataDir } from './testContext.js'; import { parseLanguages } from './transcribe.js'; +import { group } from './groups.js'; +import { PlainUi } from '../ui/plain.js'; +import { createJob, getJob, listJobs } from '../jobs/store.js'; +import { withJobLock } from '../jobs/lock.js'; +import { spawnDetachedJob } from '../jobs/spawn.js'; + +vi.mock('../jobs/spawn.js', () => ({ spawnDetachedJob: vi.fn() })); + +describe('group', () => { + it('gives a noun without a plural exactly one name', () => { + const program = new Command(); + group(program, 'self', undefined, 'manage this installation'); + const self = program.commands.find((c) => c.name() === 'self')!; + expect(self.aliases()).toEqual([]); + }); + + it('still aliases a noun that has a plural', () => { + const program = new Command(); + group(program, 'report', 'reports', 'saved reports'); + expect(program.commands.find((c) => c.name() === 'report')!.aliases()).toEqual(['reports']); + }); +}); describe('ailoud import', () => { it('prints the id of an imported recording', async () => { @@ -218,6 +244,512 @@ describe('ailoud transcribe --diarize', () => { ); }); +describe('ailoud transcribe --max-cpu, --no-gpu, --denoise', () => { + afterEach(() => { + vi.mocked(spawnDetachedJob).mockReset(); + }); + + it.each(['0', '101', 'abc', '-5', '2.5'])( + 'refuses --max-cpu %s, naming the accepted range', + async (value) => { + const ctx = context(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--max-cpu', value]), + ).rejects.toThrow(/1.*100/); + }, + ); + + it('accepts a --max-cpu inside the range and forwards the resulting budget to createStt', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--max-cpu', '50']); + // testContext's fixed topology is { logical: 10, performance: 8 }: 50% of the + // 8 performance cores, rounded, is 4 -- the proof the budget actually reached + // createStt (transcribe.ts:333) rather than that factory's own "no budget" + // fallback of 4. Checked against ctx.sttBudgets rather than a shared array: + // a shared array would still pass if this call site's own argument were + // dropped, as long as some other factory in the run still received a + // budget -- which is exactly the hole a whole-branch review found. + expect(ctx.sttBudgets).toEqual([expect.objectContaining({ threads: 4, gpu: true })]); + }); + + it('uses the configured default share when --max-cpu is not given', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe']); + // 90% (the schema default) of 8 performance cores, rounded, is 7 -- distinct + // from both 4 above and the factory's own unrelated fallback of 4, so this + // could not pass by accident. + expect(ctx.sttBudgets).toEqual([expect.objectContaining({ threads: 7, gpu: true })]); + }); + + it('--no-gpu forwards gpu: false, never true', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--no-gpu']); + expect(ctx.sttBudgets).toEqual([expect.objectContaining({ gpu: false })]); + }); + + it('forwards the resulting budget to createSegmenter under --multilingual', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + // createSegmenter(budget) (transcribe.ts:334) runs before the pipeline + // checks the fake provider's capabilities, so the expected failure below + // (the default fake cannot detect a language -- see the sibling + // "--multilingual reaches the pipeline" test) happens after the budget + // has already reached the factory and is no obstacle to asserting on it. + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + '--multilingual', + '--max-cpu', + '50', + ]), + ).rejects.toThrow(/cannot detect a language/); + // Pins transcribe.ts:334 (createSegmenter(budget)). Before this test + // existed, deleting the budget argument at this call site left build, + // lint, typecheck and every unit test green -- the segmenter fell back to + // its factory's own "no budget" default of 4 threads regardless of + // --max-cpu, silently. + expect(ctx.segmenterBudgets).toEqual([expect.objectContaining({ threads: 4, gpu: true })]); + }); + + it('forwards the resulting budget to createDiarizer under --diarize', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + '--diarize', + '--max-cpu', + '50', + ]); + // Pins transcribe.ts:335 (createDiarizer(budget)). + expect(ctx.diarizerBudgets).toEqual([expect.objectContaining({ threads: 4, gpu: true })]); + }); + + it('refuses an unknown --denoise mode, naming the three accepted ones', async () => { + const ctx = context(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--denoise', 'sometimes']), + ).rejects.toThrow(UsageError); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--denoise', 'sometimes']), + ).rejects.toThrow(/auto.*on.*off/); + }); + + it.each(['auto', 'on', 'off'])( + 'accepts --denoise %s and forwards it to the audio tool', + async (mode) => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--denoise', mode]); + expect((ctx.audio as FakeAudioTool).denoiseModes).toContain(mode); + }, + ); + + it('defaults to "off" (the schema default) when --denoise is not given', async () => { + // Asserted against the mode the adapter was ASKED for, not against the + // config: this is the wiring between the two, and it is what silently + // broke when the flag was added to summarize where nothing consumed it. + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe']); + expect((ctx.audio as FakeAudioTool).denoiseModes).toContain('off'); + expect((ctx.audio as FakeAudioTool).denoiseModes).not.toContain('auto'); + }); + + it('validates --max-cpu and --denoise before creating a job or spawning anything, under --detach', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + '--max-cpu', + '0', + '--detach', + ]), + ).rejects.toThrow(/1.*100/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + '--denoise', + 'sometimes', + '--detach', + ]), + ).rejects.toThrow(/auto.*on.*off/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('forwards --max-cpu, --no-gpu and --denoise to the detached child, unmodified', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + 'ID001', + '--max-cpu', + '50', + '--no-gpu', + '--denoise', + 'on', + '--detach', + ]); + expect(spawnDetachedJob).toHaveBeenCalledTimes(1); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual([ + 'transcribe', + 'ID001', + '--max-cpu', + '50', + '--no-gpu', + '--denoise', + 'on', + ]); + }); + }); + + it('forwards none of the three to the detached child when nothing was asked for', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', 'ID001', '--detach']); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).not.toContain('--max-cpu'); + expect(commandArgs).not.toContain('--no-gpu'); + expect(commandArgs).not.toContain('--denoise'); + }); + }); +}); + +/** Captures every `(stage, fraction)` pair `transcribing` reports, in order. */ +class SpyUi extends PlainUi { + public readonly reports: Array<{ readonly stage: string; readonly fraction: number }> = []; + + public override async transcribing( + _recording: Recording, + task: (report: (stage: string, fraction: number) => void) => Promise, + ): Promise { + return task((stage, fraction) => { + this.reports.push({ stage, fraction }); + }); + } +} + +describe('ailoud transcribe: an unmeasurable stage never lowers the percentage', () => { + it('reuses the last fraction when the diarizer reports its stage with none of its own', async () => { + const ctx = context(); + const spy = new SpyUi((line) => ctx.lines.push(line)); + // `ui` is declared readonly on CliContext; Object.assign does not go + // through that check, and this test's entire job is to swap it out for + // one that records what transcribing() reports. + Object.assign(ctx, { ui: spy }); + // Drives transcribe()'s onProgress up near the end of the transcribing + // stage before diarizing (which reports no fraction of its own) starts. + ctx.createStt = () => { + const stt = new FakeStt( + { + language: 'ru', + model: 'base.bin', + segments: [{ startMs: 0, endMs: 1500, text: 'Privet.' }], + }, + undefined, + [], + [0.5, 1], + ); + ctx.sttInstances.push(stt); + return stt; + }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--diarize']); + + const fractions = spy.reports.map((r) => r.fraction); + for (let i = 1; i < fractions.length; i += 1) { + expect(fractions[i]).toBeGreaterThanOrEqual(fractions[i - 1]!); + } + // The diarizer's first event carries no fraction of its own (see + // transcribeRecording's "No fraction" comment). Reused, not treated as + // 0, so the number the UI was told does not walk backwards. + const noFractionStage = spy.reports.findIndex((r) => r.stage === 'diarizing'); + expect(noFractionStage).toBeGreaterThan(0); + expect(spy.reports[noFractionStage]!.fraction).toBe(spy.reports[noFractionStage - 1]!.fraction); + }); +}); + +describe('ailoud transcribe --job', () => { + it('is hidden from --help', () => { + const ctx = context(); + const program = buildProgram(ctx); + const transcribeCmd = program.commands.find((c) => c.name() === 'transcribe')!; + const jobOption = transcribeCmd.options.find((o) => o.long === '--job'); + expect(jobOption?.hidden).toBe(true); + }); + + it('never appears in rendered --help text either', () => { + // The option-object check above pins commander's `hidden` flag, but not + // that commander actually honours it when rendering. Checked against the + // pinned commander version in use. + const ctx = context(); + const program = buildProgram(ctx); + const transcribeCmd = program.commands.find((c) => c.name() === 'transcribe')!; + expect(transcribeCmd.helpInformation()).not.toContain('--job'); + }); + + it('rejects an id with no matching job', async () => { + const ctx = context(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--job', 'nope']), + ).rejects.toThrow(UsageError); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--job', 'nope']), + ).rejects.toThrow(/nope/); + }); + + it('reports success into the job state file', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--job', job.id]); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('done'); + expect(state?.percent).toBe(100); + // The full four-field shape the spec asks for (recordingId, + // transcriptId, language, segments) -- not just the recording id the + // caller already had. transcriptId is 'ID003': 'ID001' is the + // recording (import, above), 'ID002' is the job itself (createJob, + // above), and the pipeline's own ids.next() calls start after that. + expect(state?.result).toEqual({ + transcribed: [ + { + recordingId: 'ID001', + transcriptId: 'ID003', + language: 'ru', + segments: 1, + // Empty because the fake transcriber attributes nothing: this + // run had no diarization. The field is what `job_status` reads + // to decide whether to ask the user for speaker names. + speakers: [], + }, + ], + }); + }); + }); + + it('records a failure when the job lock is already held on the way in', async () => { + // I2: withJobLock itself can throw, before body() -- and therefore + // transcribeRecording -- ever runs, which is exactly what losing the + // advisory race against another process looks like. The try/catch used + // to sit inside withJobLock's own callback and never saw this throw, so + // the state file stayed 'running' forever with nothing to explain why. + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + await withJobLock(ctx.paths.dataDir, async () => { + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--job', job.id]), + ).rejects.toThrow(FailureError); + }); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('failed'); + expect(state?.error).toMatch(/already running/); + }); + }); + + it('reports a failure into the job state file and still rethrows, exit code unchanged', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', 'ID999', '--job', job.id]), + ).rejects.toThrow(FailureError); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('failed'); + expect(state?.error).toContain('ID999'); + }); + }); +}); + +describe('ailoud transcribe --detach', () => { + afterEach(() => { + vi.mocked(spawnDetachedJob).mockReset(); + }); + + it('is not hidden from --help, unlike --job', () => { + const ctx = context(); + const program = buildProgram(ctx); + const transcribeCmd = program.commands.find((c) => c.name() === 'transcribe')!; + const detachOption = transcribeCmd.options.find((o) => o.long === '--detach'); + expect(detachOption?.hidden).toBeFalsy(); + }); + + it('rejects --detach together with --job before doing anything', async () => { + const ctx = context(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--detach', '--job', 'X']), + ).rejects.toThrow(UsageError); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + }); + + it('validates --lang before creating a job or spawning anything', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + '--lang', + 'xx yy', + '--detach', + ]), + ).rejects.toThrow(UsageError); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('validates --speakers before creating a job or spawning anything', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + '--speakers', + '2', + '--detach', + ]), + ).rejects.toThrow(/--speakers needs --diarize/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('refuses an empty default selection before creating a job or spawning anything', async () => { + // The default selector means "everything not yet transcribed"; once + // every recording already has a transcript, --detach would otherwise + // hand back a job id for a child that does nothing at all. + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe']); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--detach']), + ).rejects.toThrow(/--detach has nothing to transcribe/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('refuses when another job already holds the lock, without creating a job', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await withJobLock(ctx.paths.dataDir, async () => { + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--detach']), + ).rejects.toThrow(FailureError); + }); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('creates a running job, spawns the build args without --detach, and returns at once', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + 'ID001', + '--lang', + 'en', + '--detach', + ]); + expect(spawnDetachedJob).toHaveBeenCalledTimes(1); + const [, commandArgs, job] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual(['transcribe', 'ID001', '--lang', 'en']); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('running'); + expect(ctx.lines.join('\n')).toContain(job.id); + }); + }); + + it('preserves a --tag value that is itself the literal string "--detach"', async () => { + // The child args used to be built by filtering process.argv for the + // string '--detach', which stripped every occurrence -- including one + // that was actually the value of --tag, not the flag -- and left the + // child with a dangling '--tag' and no value. Building from the parsed + // options instead means only the real flag is ever left out. + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'transcribe', + 'ID001', + '--tag', + '--detach', + '--detach', + ]); + expect(spawnDetachedJob).toHaveBeenCalledTimes(1); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual(['transcribe', 'ID001', '--tag', '--detach']); + }); + }); + + it('marks the job failed and rethrows when spawning itself throws', async () => { + const ctx = context(); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', '/in/a.mp3']); + vi.mocked(spawnDetachedJob).mockImplementation(() => { + throw new Error('spawn boom'); + }); + const before = new Set((await listJobs(ctx.fs, ctx.paths.jobsDir)).map((j) => j.id)); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe', '--detach']), + ).rejects.toThrow(/spawn boom/); + const after = await listJobs(ctx.fs, ctx.paths.jobsDir); + const created = after.find((job) => !before.has(job.id)); + expect(created?.state).toBe('failed'); + expect(created?.error).toContain('spawn boom'); + }); + }); +}); + describe('parseLanguages', () => { it('treats an absent flag and "auto" alike, as nothing declared', () => { expect(parseLanguages(undefined)).toEqual([]); diff --git a/apps/cli/src/commands/doctor.test.ts b/apps/cli/src/commands/doctor.test.ts index 517fd45..ec40bc9 100644 --- a/apps/cli/src/commands/doctor.test.ts +++ b/apps/cli/src/commands/doctor.test.ts @@ -20,6 +20,7 @@ import { checkVadModel, registerDoctor, runChecks, + setupNote, } from './doctor.js'; import { collectRemedies } from './setup.js'; @@ -222,6 +223,10 @@ describe('checkLanguageModel', () => { ); expect(check.ok).toBe(true); expect(check.detail).toContain(process.execPath); + // download-llm-model REPAIRS exactly what this check inspects (the local + // GGUF file) -- unlike the claude-cli branch below, it belongs on the + // passing branch too, so --force can reinstall it. + expect(check.remedy).toEqual({ kind: 'download-llm-model' }); }); it('wants a key for a hosted endpoint, and says keys never live in the config file', async () => { @@ -291,6 +296,12 @@ describe('checkLanguageModel', () => { ); expect(check.ok).toBe(true); expect(check.detail).toContain('via subscription'); + // install-llm is a SUBSTITUTE here (a fallback local model), not a repair + // of the Claude Code CLI this check actually inspects -- it must not + // survive onto a passing check, or --force would brew-install llama.cpp + // for someone whose Claude Code works fine. See Check.remedy's doc + // comment for the general rule this is the example of. + expect(check.remedy).toBeUndefined(); }); it('names the config key to switch away when the Claude CLI is absent', async () => { @@ -372,15 +383,17 @@ describe('checkBinary', () => { expect(check.remedy).toEqual({ kind: 'install-ffmpeg' }); }); - it('attaches no remedy to a passing check', async () => { + it('keeps the remedy on a passing check too, so --force can still reinstall it', async () => { // node is guaranteed present in the test environment and exits 0 on // --version, unlike ffmpeg or whisper-cli which this suite cannot - // assume are installed. + // assume are installed. `Check.remedy` means "repairable", not + // "currently broken" -- see its doc comment -- and `ailoud setup --force` + // reads it off passing checks to reinstall something that already works. const check = await checkBinary('node', 'node', ['--version'], 'install it', undefined, { kind: 'install-ffmpeg', }); expect(check.ok).toBe(true); - expect(check.remedy).toBeUndefined(); + expect(check.remedy).toEqual({ kind: 'install-ffmpeg' }); }); }); @@ -394,15 +407,17 @@ describe('checkModel', () => { expect(check.remedy).toEqual({ kind: 'download-model', slot: 'transcription' }); }); - it('attaches no remedy to a passing check', async () => { + it('keeps the remedy on a passing check too, for --force and for switching models', async () => { // process.execPath is a real file guaranteed to exist on disk, so the // access() check this exercises succeeds without needing a fixture. + // `ailoud setup --model ` on a machine whose configured model is + // already present needs this remedy to switch models at all. const check = await checkModel('/c', process.execPath, { kind: 'download-model', slot: 'transcription', }); expect(check.ok).toBe(true); - expect(check.remedy).toBeUndefined(); + expect(check.remedy).toEqual({ kind: 'download-model', slot: 'transcription' }); }); }); @@ -497,6 +512,109 @@ describe('runChecks', () => { }); }); +describe('acceleration checks', () => { + // context()'s default whisper binary ('w', looked up on PATH) points at a + // real path that does not exist on disk, so probeBackends and checkBinary + // see the same ENOENT a machine with no whisper.cpp installed at all would. + // Not '/no/such/whisper-cli' but a MemFs-unrelated real path, because both + // probeBackends and checkBinary spawn a real child process (they never + // touch context.fs), so only a genuinely absent path on the real + // filesystem reproduces "the binary is missing". + function contextWithMissingBinaries(): CliContext { + const ctx = context(); + return { + ...ctx, + config: { + ...ctx.config, + stt: { + ...ctx.config.stt, + whisperCpp: { ...ctx.config.stt.whisperCpp, binary: '/no/such/whisper-cli' }, + }, + }, + }; + } + + it('reports the cores and the thread counts derived from them', async () => { + const checks = await runChecks(context()); + const cpu = checks.find((c) => c.name === 'cpu'); + expect(cpu?.ok).toBe(true); + // Both numbers, because they differ and the difference is the point. + expect(cpu?.detail).toMatch(/threads/); + expect(cpu?.detail).toMatch(/segmentation/); + expect(cpu?.detail).toMatch(/diarization/); + }); + + it('names the backends the whisper binary loaded', async () => { + const checks = await runChecks(context()); + expect(checks.find((c) => c.name === 'whisper backends')).toBeDefined(); + }); + + it('marks the neural engine unavailable without failing readiness', async () => { + // CoreML in whisper.cpp is a compile-time option plus a converted model. + // There is no runtime flag, and homebrew does not build it. Reporting + // that must not make doctor say the machine is broken. + const checks = await runChecks(context()); + const ane = checks.find((c) => c.name === 'neural engine'); + expect(ane?.optional).toBe(true); + expect(checks.filter(blocksReadiness)).not.toContain(ane); + }); + + it('carries no remedy on any of them, so --fix ignores them', async () => { + const checks = await runChecks(context()); + for (const name of ['cpu', 'whisper backends', 'neural engine']) { + expect(checks.find((c) => c.name === name)?.remedy).toBeUndefined(); + } + }); + + it('does not fail when the whisper binary is missing entirely', async () => { + const checks = await runChecks(contextWithMissingBinaries()); + const backends = checks.find((c) => c.name === 'whisper backends'); + expect(backends?.optional).toBe(true); + expect(backends?.ok).toBe(false); + }); +}); + +describe('setupNote', () => { + it('tells a GPU machine that threads are for diarization', () => { + const note = setupNote(['MTL', 'BLAS', 'CPU'], 90); + expect(note).toContain('GPU build'); + expect(note).toMatch(/diarization/); + // Must NOT tell a GPU machine to raise threads for transcription: on a + // GPU build that is worth 0.7 s on 40 s of audio. + expect(note).not.toMatch(/ten times slower/); + }); + + it('tells a CPU-only machine that threads are worth multiples', () => { + const note = setupNote(['BLAS', 'CPU'], 90); + expect(note).toContain('CPU-only'); + expect(note).toMatch(/four times/); + // Not a substring check against "GPU build": the CPU-only sentence + // legitimately contains that phrase ("...slower than on a GPU build..."). + // What must not happen is the note being LABELLED as the GPU case. + expect(note?.startsWith('GPU build')).toBe(false); + }); + + it('treats every ggml gpu backend name as a gpu', () => { + for (const name of ['MTL', 'CUDA', 'ROCM', 'VULKAN', 'SYCL']) { + expect(setupNote([name, 'CPU'], 90)).toContain('GPU build'); + } + }); + + it('says nothing at all when the binary could not be asked', () => { + // Better silent than inventing advice about a build nobody inspected. + expect(setupNote([], 90)).toBeNull(); + }); + + it('never mentions the flag an agent should not ask about', () => { + // The rule: an agent must understand what makes ailoud fast without being + // invited to interrogate the user about --max-cpu. The note names the + // config key, which a user edits once, not the per-run flag. + for (const backends of [['MTL', 'CPU'], ['CPU']]) { + expect(setupNote(backends, 90)).not.toContain('--max-cpu'); + } + }); +}); + /** * `doctor --fix` (registerDoctor, apps/cli/src/commands/doctor.ts) builds * its remedy list the same way registerSetup does: filter runChecks' output @@ -546,10 +664,13 @@ describe('doctor --fix scope: remedies come only from failing checks', () => { ...context(), paths: { configFile: join(scopedDir, 'config.yaml'), + configHome: scopedDir, dataDir: scopedDir, dbFile: join(scopedDir, 'ailoud.db'), mediaRoot: join(scopedDir, 'media'), + jobsDir: join(scopedDir, 'jobs'), isProjectLibrary: false, + userDataDir: scopedDir, }, config: { stt: { @@ -580,6 +701,9 @@ describe('doctor --fix scope: remedies come only from failing checks', () => { model: join(scopedDir, 'llm-model.gguf'), }, }, + resources: parseConfig(null).resources, + audio: parseConfig(null).audio, + update: parseConfig(null).update, }, }; } @@ -687,10 +811,13 @@ describe('doctor: an unconfigured optional feature does not mean "not ready"', ( ...context(), paths: { configFile: join(dataDir, 'config.yaml'), + configHome: dataDir, dataDir, dbFile: join(dataDir, 'ailoud.db'), mediaRoot: join(dataDir, 'media'), + jobsDir: join(dataDir, 'jobs'), isProjectLibrary: false, + userDataDir: dataDir, }, config: { stt: { @@ -710,6 +837,9 @@ describe('doctor: an unconfigured optional feature does not mean "not ready"', ( }, }, llm: parseConfig(null).llm, + resources: parseConfig(null).resources, + audio: parseConfig(null).audio, + update: parseConfig(null).update, }, }; } @@ -829,10 +959,13 @@ describe('a corrupt database: every entry point must refuse', () => { ...ctx, paths: { configFile: join(corruptDir, 'config.yaml'), + configHome: corruptDir, dataDir: corruptDir, dbFile: join(corruptDir, 'ailoud.db'), mediaRoot: join(corruptDir, 'media'), + jobsDir: join(corruptDir, 'jobs'), isProjectLibrary: false, + userDataDir: corruptDir, }, config: { stt: { @@ -864,6 +997,9 @@ describe('a corrupt database: every entry point must refuse', () => { model: join(corruptDir, 'llm-model.gguf'), }, }, + resources: parseConfig(null).resources, + audio: parseConfig(null).audio, + update: parseConfig(null).update, }, }; } @@ -930,7 +1066,7 @@ describe('a corrupt database: every entry point must refuse', () => { * The Windows guard lives in runProvisioning (the shared engine) now, not * in registerSetup, precisely so `doctor --fix` inherits it too. Before * this fix, `ailoud doctor --fix --yes` on win32 built a plan, took consent, - * downloaded the transcription model and the VAD model (up to 1.6 GB), and + * downloaded the transcription model and the VAD model (up to 3.1 GB), and * only then failed both installs and exited non-zero. registerDoctor is * called directly (not through buildProgram) so `platform` can be pinned to * 'win32' without a real Windows box, mirroring the equivalent diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index cc55640..8bf77ae 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -1,8 +1,8 @@ import { access, constants, stat } from 'node:fs/promises'; import type { Command } from 'commander'; -import { EnvironmentError, installHint } from '@ailoud/core'; +import { EnvironmentError, installHint, isHostedLlm } from '@ailoud/core'; import type { Remedy } from '@ailoud/core'; -import { run } from '@ailoud/providers'; +import { cpuTopology, probeBackends, run } from '@ailoud/providers'; import type { CliContext } from '../wiring.js'; import type { Check } from '../ui/index.js'; import type { AiloudConfig } from '../config.js'; @@ -44,17 +44,22 @@ export async function checkBinary( detailOverride?: string, remedy?: Remedy, ): Promise { + // `remedy` is attached on every branch below, passing included -- see + // `Check.remedy`'s doc comment for why, and for the one caveat: a caller + // whose `remedy` is a substitute rather than a repair of the exact thing + // this function checked (checkLanguageModel's claude-cli branch is the + // example) must strip it back off its own passing result. try { const result = await run(binary, args, { timeoutMs: 10_000 }); if (result.code !== 0) { return { name, ok: false, detail: `exited with code ${result.code}`, fix, remedy }; } if (detailOverride !== undefined) { - return { name, ok: true, detail: detailOverride }; + return { name, ok: true, detail: detailOverride, remedy }; } const output = result.stdout.length > 0 ? result.stdout : result.stderr; const firstLine = output.split('\n')[0]?.trim() ?? ''; - return { name, ok: true, detail: firstLine }; + return { name, ok: true, detail: firstLine, remedy }; } catch (error) { return { name, ok: false, detail: summarizeRunFailure(error), fix, remedy }; } @@ -72,7 +77,11 @@ export async function checkModel( } try { await access(modelPath, constants.F_OK); - return { name, ok: true, detail: modelPath }; + // Attached on the passing branch too, not only the two failing ones: + // `ailoud setup --model ` on a machine whose configured model is + // already present and healthy still needs this remedy, to switch models + // rather than doing nothing (see collectRemedies's `switchingModel`). + return { name, ok: true, detail: modelPath, remedy }; } catch { return { name, ok: false, detail: `file not found: ${modelPath}`, fix, remedy }; } @@ -104,7 +113,10 @@ export async function checkVadModel( } try { await access(vadModelPath, constants.F_OK); - return { name, ok: true, detail: vadModelPath, optional: true }; + // See checkModel's matching comment: a passing check keeps its remedy so + // `--force` can still act on it. (There is no `--vad-model` flag, so only + // `force` -- never `switchingModel` -- ever widens this one.) + return { name, ok: true, detail: vadModelPath, remedy, optional: true }; } catch { return { name, ok: false, detail: `missing: ${vadModelPath}`, fix, remedy, optional: true }; } @@ -181,9 +193,12 @@ export async function checkVadBinary( * platform ('run "ailoud setup" (ailoud setup)'). The hint alone, exactly as * checkVadBinary uses it. * - * NOT VERIFIED AGAINST A REAL BUILD: like the whisper-cli check above, this - * assumes sherpa-onnx-offline-speaker-diarization exits 0 on "--help". No - * such binary is available in this environment to confirm that. + * VERIFIED against a real build, the same way the whisper-cli check in + * runChecks below is: sherpa-onnx-offline-speaker-diarization v1.13.6 does + * exit 0 on "--help" (confirmed at the configured + * `~/.local/share/ailoud/sherpa/v1.13.6/bin/` path; the binary is not on + * PATH). This comment used to say no binary was available to check that; + * one is, and it agrees. * * `optional: true` on every branch: diarization is opt-in (`--diarize`), so * this binary being missing means one feature is unavailable, not that ailoud @@ -248,7 +263,9 @@ export async function checkSegmentationModel( } try { await access(segmentationModelPath, constants.F_OK); - return { name, ok: true, detail: segmentationModelPath, optional: true }; + // See checkModel's matching comment: a passing check keeps its remedy so + // `--force` can still act on it. + return { name, ok: true, detail: segmentationModelPath, remedy, optional: true }; } catch { return { name, @@ -276,7 +293,9 @@ export async function checkEmbeddingModel( } try { await access(embeddingModelPath, constants.F_OK); - return { name, ok: true, detail: embeddingModelPath, optional: true }; + // See checkModel's matching comment: a passing check keeps its remedy so + // `--force` can still act on it. + return { name, ok: true, detail: embeddingModelPath, remedy, optional: true }; } catch { return { name, @@ -322,8 +341,18 @@ export async function checkLanguageModel( }); return { ...result, + // `install-llm` here is a SUBSTITUTE for a missing Claude Code CLI -- + // "install a local model instead" -- not a repair of the CLI itself, + // which is exactly what checkBinary's now-passing remedy would + // otherwise carry forward (see Check.remedy's doc comment on that + // distinction). Stripped back off on a passing check: `--force` must + // never brew-install llama.cpp for someone whose Claude Code is fine + // and who never asked for a local summariser. ...(result.ok - ? { detail: `${llm.claudeCli.binary} (${llm.claudeCli.model}, via subscription)` } + ? { + detail: `${llm.claudeCli.binary} (${llm.claudeCli.model}, via subscription)`, + remedy: undefined, + } : {}), optional: true, }; @@ -334,8 +363,10 @@ export async function checkLanguageModel( const key = apiKeyFrom(env, variable); const settings = llm.provider === 'anthropic' ? llm.anthropic : llm.openaiCompatible; // A local OpenAI-compatible server needs no key, so its absence is only a - // problem when the endpoint is a hosted one. - const hosted = /api\.(openai|anthropic)\.com/.test(settings.baseUrl); + // problem when the endpoint is a hosted one. By hostname: the pattern this + // replaced matched `https://api.openai.com.example.net` and + // `https://example.net/?x=api.openai.com` alike. + const hosted = isHostedLlm(settings.baseUrl); if (key === undefined && hosted) { return { name, @@ -378,7 +409,16 @@ export async function checkLanguageModel( optional: true, }; } - return { name, ok: true, detail: settings.model, optional: true }; + // Remedy kept on the passing branch too, matching every other model/binary + // check: `--force` re-downloads this alongside everything else, the same + // "widest scope, no carve-outs" rule the ffmpeg/whisper checks follow. + return { + name, + ok: true, + detail: settings.model, + remedy: { kind: 'download-llm-model' }, + optional: true, + }; } /** @@ -485,11 +525,10 @@ export async function runChecks( undefined, { kind: 'install-ffmpeg' }, ), - // NOT VERIFIED AGAINST A REAL BUILD: this assumes whisper-cli exits 0 on - // "--help", the same way ffmpeg and ffprobe do on "-version". No - // whisper-cli binary is available in this environment to confirm that; - // if a real build exits non-zero for "--help" instead, this check will - // report a working binary as failing. + // VERIFIED against a real build: whisper-cli (homebrew, ggml 0.22.0) + // does exit 0 on "--help", the same way ffmpeg and ffprobe do on + // "-version". This comment used to say no binary was available to + // check that; one is, and it agrees. // Reported by the configured value, not by the command's first output // line: whisper-cli prints backend chatter ("load_backend: loaded BLAS // backend from ...") on stderr and nothing on stdout, for --help and @@ -543,9 +582,86 @@ export async function runChecks( await checkConfigFile(paths.configFile), checkDatabase(context), await checkMediaRoot(paths.mediaRoot, { kind: 'create-directory', path: paths.mediaRoot }), + ...(await accelerationChecks(context)), ]; } +/** + * Informational, not a gate: every one of these is `optional`, carries no + * remedy, and reports what the machine offers rather than whether it is + * ready. `blocksReadiness` ignores an optional failure, so a machine with no + * GPU at all still passes `doctor`. + */ +export async function accelerationChecks(context: CliContext): Promise { + const topology = await cpuTopology(); + const budget = await context.resources(); + const split = + topology.performance === null + ? `${topology.logical} logical` + : `${topology.logical} logical, ${topology.performance} performance`; + + const binary = context.config.stt.whisperCpp.binary; + const backends = await probeBackends(binary); + + return [ + { + name: 'cpu', + ok: true, + // Both numbers: they differ, and the lower one's being shared by + // segmentation and diarization is a measured decision rather than an + // accident (see budget.ts). + detail: + `${split} -> ${budget.threads} threads, ` + + `${budget.cappedThreads} for segmentation and diarization, at ` + + `${context.config.resources.maxCpuPercent}%`, + }, + backends.length > 0 + ? { name: 'whisper backends', ok: true, detail: backends.join(', ') } + : { + name: 'whisper backends', + ok: false, + optional: true, + detail: `could not ask ${binary} which backends it loads`, + }, + { + name: 'neural engine', + ok: false, + optional: true, + detail: + 'not available: whisper.cpp reaches the Neural Engine only when built with ' + + 'CoreML support and given a converted model, which the packaged build is not', + }, + ]; +} + +/** + * One line an outside agent can act on, chosen by what this machine actually + * loaded rather than printed as boilerplate. + * + * MEASURED on 40 s of audio with ggml-small.bin: 1.93 s on a Metal build at 8 + * threads, 19.61 s with the GPU disabled at 8 threads, 76.96 s with it + * disabled at 1. So the build having a GPU backend is worth about ten times + * the thread count, and on a GPU build the thread count is worth almost + * nothing (2.62 s at one thread). The advice differs completely between the + * two cases, which is why only one of them is ever printed. + * + * Null when the whisper binary could not be asked at all: `doctor` already + * reports that as a failing check, and a performance hint about a build + * nobody could inspect would be invention. + */ +export function setupNote(backends: readonly string[], maxCpuPercent: number): string | null { + if (backends.length === 0) return null; + const gpu = backends.some((name) => GPU_BACKENDS.has(name)); + return gpu + ? `GPU build (${backends.join(', ')}): transcription is already fast, and threads mainly ` + + `affect speaker diarization. Raise resources.maxCpuPercent only if diarization is slow.` + : `CPU-only build: transcription is about ten times slower than on a GPU build, and thread ` + + `count is worth about four times. resources.maxCpuPercent is ${maxCpuPercent}.`; +} + +/** ggml's names for a backend that is not the CPU. `MTL` is Metal. */ +const GPU_BACKENDS = new Set(['MTL', 'CUDA', 'ROCM', 'VULKAN', 'SYCL']); + export interface DoctorOptions extends SetupOptions { readonly fix?: boolean; } @@ -564,7 +680,10 @@ export function registerDoctor( .command('doctor') .option('--fix', 'provision anything that failed a check, using the same engine as setup') .option('--yes', 'confirm the fix plan without prompting') - .option('--model ', 'transcription model to download if one is needed (default: small)') + .option( + '--model ', + 'transcription model to download if one is needed (default: the configured one)', + ) .option('--llm ', 'summariser to set up: local, claude-cli, claude-api, openai, skip') .option( '--llm-model ', @@ -574,6 +693,13 @@ export function registerDoctor( .action(async (options: DoctorOptions) => { await context.ui.frame('Environment check', async () => { const checks = await runChecks(context, platform); + // probeBackends is memoised per binary, so this does not double the + // probe accelerationChecks (inside runChecks) already made. + const note = setupNote( + await probeBackends(context.config.stt.whisperCpp.binary), + context.config.resources.maxCpuPercent, + ); + if (note !== null) context.ui.note(note); context.ui.checks(checks); if (options.fix !== true) { if (checks.some(blocksReadiness)) { diff --git a/apps/cli/src/commands/groups.ts b/apps/cli/src/commands/groups.ts index 619169d..02c5fc9 100644 --- a/apps/cli/src/commands/groups.ts +++ b/apps/cli/src/commands/groups.ts @@ -12,6 +12,11 @@ export type Register = (parent: Command, context: CliContext) => void; * `gh pr list`, `kubectl get pod`): `ailoud report rm SUM0` reads as removing one * report, while `ailoud reports rm SUM0` reads as removing all of them. * + * `plural` is optional: `self` has no plural. `selves` would put a word in + * `--help` nobody would ever type, and passing `self` as both the name and + * the alias makes commander throw on an alias equal to the name. A noun + * declared without one gets exactly its singular name and nothing else. + * * No `.action()` handler, deliberately. A bare `ailoud audio` already prints * its verb list -- commander does that for any command with subcommands and * no action of its own -- and adding one to force it cost two behaviours that @@ -23,10 +28,12 @@ export type Register = (parent: Command, context: CliContext) => void; export function group( program: Command, name: string, - plural: string, + plural: string | undefined, description: string, ): Command { - return program.command(name).alias(plural).description(description).showHelpAfterError(); + const command = program.command(name).description(description).showHelpAfterError(); + if (plural !== undefined) command.alias(plural); + return command; } /** @@ -70,8 +77,22 @@ export function inGroupAndTopLevel( * The one-letter alias for each second-level verb. * * One table rather than a letter beside each command definition, because the - * risk here is collision and a table is where you can see it: every letter - * below appears exactly once, and the test for that reads this map. + * risk here is collision and a table is where you can see it. + * + * A letter is unique WITHIN A GROUP, not across the table: `summarize` and + * `sync` both take `s`, and that is fine because they live under different + * nouns (`audio summarize`, `self sync`) and commander resolves an alias + * against one parent's children. This comment used to claim every letter + * appeared exactly once, which the table below already contradicted -- and + * the test only checked two of the four groups, so nothing would have caught + * a real collision inside `self` or `template`. It checks all of them now. + * + * `mcp` gets NO letters, and that is deliberate rather than an oversight: + * its verbs are `install`, `uninstall` and `update`, and `uninstall` and + * `update` both want `u`. A set that cannot be made unique and complete is + * better left off entirely than half-assigned, so `program.ts` does not call + * `attachLetters` for that group. Widening the collision test is what made + * this asymmetry visible; it had never been written down. * * The same verb gets the same letter in every group -- `l` lists, `v` views, * `r` removes -- so the letters are worth learning once instead of per noun. @@ -91,6 +112,9 @@ const LETTER: Record = { // `f` for find: `s` is summarize, and search is the verb people reach for // most often after `ls`. search: 'f', + check: 'c', + update: 'u', + sync: 's', }; /** Every letter this build assigns, for the collision test to read. */ diff --git a/apps/cli/src/commands/jobs.test.ts b/apps/cli/src/commands/jobs.test.ts new file mode 100644 index 0000000..05b451c --- /dev/null +++ b/apps/cli/src/commands/jobs.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { buildProgram } from '../program.js'; +import { context } from './testContext.js'; +import { writeJobState } from '../jobs/state.js'; +import type { JobState } from '../jobs/state.js'; + +function job(partial: Partial = {}): JobState { + return { + id: 'JOB00000000000000000000001', + kind: 'transcribe', + state: 'done', + percent: 100, + stage: 'done', + pid: process.pid, + startedAt: '2026-09-07T08:00:00.000Z', + finishedAt: '2026-09-07T08:05:00.000Z', + recordings: { total: 1, done: 1 }, + declared: null, + log: '/d/jobs/JOB00000000000000000000001.log', + result: null, + error: null, + ...partial, + }; +} + +describe('ailoud job ls', () => { + it('says so plainly when there are no jobs', async () => { + const ctx = context(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'ls']), + ).resolves.toBeDefined(); + expect(ctx.lines.join('\n')).toMatch(/No background jobs yet/); + }); + + it('emits an empty array for --json rather than an error', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'ls', '--json']); + expect(ctx.lines.join('')).toContain('[]'); + }); + + it('lists jobs newest first, with state and percentage', async () => { + const ctx = context(); + await writeJobState( + ctx.fs, + ctx.paths.jobsDir, + job({ id: 'JOB00000000000000000000001', state: 'running', percent: 40 }), + ); + await writeJobState( + ctx.fs, + ctx.paths.jobsDir, + job({ id: 'JOB00000000000000000000002', state: 'done', percent: 100 }), + ); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'ls']); + const out = ctx.lines.join('\n'); + expect(out).toContain('running'); + expect(out).toContain('40%'); + expect(out.indexOf('JOB00000000000000000000002')).toBeLessThan( + out.indexOf('JOB00000000000000000000001'), + ); + }); + + it('dispatches on the one-letter alias', async () => { + const ctx = context(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, job()); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'l']); + expect(ctx.lines.join('\n')).toContain(job().id); + }); +}); + +describe('ailoud job show', () => { + it('shows one job as json, including the log path', async () => { + const ctx = context(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, job()); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'show', job().id, '--json']); + const parsed = JSON.parse(ctx.lines.join('')) as JobState; + expect(parsed.id).toBe(job().id); + expect(parsed.log).toBe(job().log); + }); + + it('shows one job as text, including its log path', async () => { + const ctx = context(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, job()); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'show', job().id]); + const out = ctx.lines.join('\n'); + expect(out).toContain(job().id); + expect(out).toContain(job().log); + }); + + it('reports an unknown id as unknown, distinctly from a failure', async () => { + const ctx = context(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, job({ id: 'JOB00000000000000000000009' })); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'show', 'NOSUCHJOB']), + ).rejects.toThrow(/UNKNOWN/); + // A job that genuinely failed must not read the same way. + await writeJobState( + ctx.fs, + ctx.paths.jobsDir, + job({ id: 'JOB00000000000000000000003', state: 'failed', error: 'ffmpeg crashed' }), + ); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'job', + 'show', + 'JOB00000000000000000000003', + ]); + expect(ctx.lines.join('\n')).not.toMatch(/UNKNOWN/); + }); +}); + +describe('ailoud job rm', () => { + it('removes a finished job and its log', async () => { + const ctx = context(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, job()); + await ctx.fs.writeTextFile(job().log, 'log contents'); + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'rm', job().id]); + expect(ctx.lines.join('\n')).toContain('removed'); + await expect(ctx.fs.exists(`${ctx.paths.jobsDir}/${job().id}.json`)).resolves.toBe(false); + await expect(ctx.fs.exists(job().log)).resolves.toBe(false); + }); + + it('refuses to remove a running job', async () => { + const ctx = context(); + // A live pid, so withLiveness (applied by getJob) does not correct this + // to `failed` before rm ever sees it: this process itself is running. + await writeJobState(ctx.fs, ctx.paths.jobsDir, job({ state: 'running', pid: process.pid })); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'rm', job().id]), + ).rejects.toThrow(/does not stop it|cannot stop a running job/); + await expect(ctx.fs.exists(`${ctx.paths.jobsDir}/${job().id}.json`)).resolves.toBe(true); + }); + + it('reports an unknown id as unknown rather than removing nothing silently', async () => { + const ctx = context(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'job', 'rm', 'NOSUCHJOB']), + ).rejects.toThrow(/UNKNOWN/); + }); +}); diff --git a/apps/cli/src/commands/jobs.ts b/apps/cli/src/commands/jobs.ts new file mode 100644 index 0000000..dff97ff --- /dev/null +++ b/apps/cli/src/commands/jobs.ts @@ -0,0 +1,150 @@ +import type { Command } from 'commander'; +import { FailureError } from '@ailoud/core'; +import type { CliContext } from '../wiring.js'; +import { getJob, listJobs, removeJob } from '../jobs/store.js'; +import type { JobState } from '../jobs/state.js'; + +interface JobsOptions { + readonly json?: boolean; +} + +/** + * Fetches a job by its exact id, or explains that ailoud has lost track of + * it. + * + * Reported as UNKNOWN rather than folded into the generic "no such job" + * phrasing every other resolver in this codebase uses: a pruned or + * mistyped id means "ailoud never had this job, or no longer does", which + * is a different fact from the job itself having failed. Saying "unknown" + * keeps a reader from chasing an error that never happened. + * + * A plain exact match, unlike `resolveRecording`/`resolveSummary`: a job id + * is copied whole from a `--detach` result or an `ls`, never typed by + * hand, so there is no prefix to resolve and no ambiguity to report. + */ +async function requireJob(context: CliContext, id: string): Promise { + const job = await getJob(context.fs, context.paths.jobsDir, id); + if (job !== null) return job; + throw new FailureError( + `Job "${id}" is UNKNOWN -- ailoud has no record of it. It may have been ` + + 'removed already, pruned automatically, or never existed. This is not ' + + 'the same as the job having failed.', + ); +} + +/** One listing row, aligned so ids and states line up, as `report ls` does for summaries. */ +function listing(jobs: readonly JobState[]): string { + const idWidth = Math.max(...jobs.map((job) => job.id.length)); + const kindWidth = Math.max(...jobs.map((job) => job.kind.length)); + const stateWidth = Math.max(...jobs.map((job) => job.state.length)); + return jobs + .map((job) => + [ + job.id.padEnd(idWidth), + job.kind.padEnd(kindWidth), + job.state.padEnd(stateWidth), + `${job.percent}%`.padStart(4), + job.stage, + ].join(' '), + ) + .join('\n'); +} + +/** The full state of one job, in the order a reader wants to check it. */ +function details(job: JobState): string { + const lines = [ + `Job ${job.id} -- ${job.kind}, ${job.state}`, + `Progress: ${job.percent}% (${job.stage})`, + `Recordings: ${job.recordings.done}/${job.recordings.total}`, + `Started: ${job.startedAt}`, + ]; + if (job.finishedAt !== null) lines.push(`Finished: ${job.finishedAt}`); + if (job.etaSeconds !== undefined) lines.push(`ETA: ${job.etaSeconds}s`); + if (job.declared !== null) { + const languages = + job.declared.languages.length === 0 ? 'unspecified' : job.declared.languages.join(', '); + lines.push(`Declared: ${job.declared.speakers} speakers, languages ${languages}`); + } + if (job.error !== null) lines.push(`Error: ${job.error}`); + // For a finished summarize job, reportId lives only here -- with no + // --json, this is the sole pointer to the saved output. docs/usage/cli.md + // promises this command "prints one job in full". + if (job.result !== null) lines.push(`Result: ${JSON.stringify(job.result)}`); + lines.push(`Log: ${job.log}`); + return lines.join('\n'); +} + +export function registerJobs(parent: Command, context: CliContext): void { + parent + .command('ls') + .option('--json', 'print JSON instead of text') + .description('List background jobs, newest first') + .action(async (options: JobsOptions) => { + await context.ui.frame('Jobs', async () => { + const jobs = await listJobs(context.fs, context.paths.jobsDir); + + if (jobs.length === 0) { + if (options.json === true) { + context.ui.content('[]'); + return; + } + // Exit 0, not a failure -- the same bargain `report ls` struck for + // an empty library: nothing here yet is not an error. + context.ui.note('No background jobs yet. Run a command with --detach to start one.'); + return; + } + + if (options.json === true) { + context.ui.content(JSON.stringify(jobs)); + return; + } + context.ui.content(listing(jobs)); + }); + }); + + parent + .command('show') + .argument('', 'a job id, exactly as printed by --detach or by "job ls"') + .option('--json', 'print JSON instead of text') + .description('Print one job in full, including its log path') + .action(async (id: string, options: JobsOptions) => { + await context.ui.frame('Job', async () => { + const job = await requireJob(context, id); + if (options.json === true) { + context.ui.content(JSON.stringify(job)); + return; + } + context.ui.content(details(job)); + }); + }); + + parent + .command('rm') + .argument('', 'a job id to forget') + .description('Forget a finished job and its log') + .action(async (id: string) => { + await context.ui.frame('Removing job', async () => { + const job = await requireJob(context, id); + if (job.state === 'running') { + // The only place a user meets this limitation, so it is spelled + // out rather than hinted at: forgetting the state file does not + // touch the process it describes, and ailoud cannot stop that + // process yet -- not "will not", "cannot". + throw new FailureError( + `Job ${id} is still running. Removing it here would only forget ` + + 'about it -- it would not stop the underlying process, and ' + + 'ailoud cannot stop a running job yet. Wait for it to finish, ' + + `or stop pid ${job.pid} yourself, then remove it.`, + ); + } + + const removed = await removeJob(context.fs, context.paths.jobsDir, id); + const line = `${id} ${removed ? 'removed' : 'was already gone'}`; + if (removed) { + context.ui.success(line); + } else { + context.ui.note(line); + } + }); + }); +} diff --git a/apps/cli/src/commands/ls.ts b/apps/cli/src/commands/ls.ts index 8f67f21..6b20cb1 100644 --- a/apps/cli/src/commands/ls.ts +++ b/apps/cli/src/commands/ls.ts @@ -37,7 +37,14 @@ export function registerLs(program: Command, context: CliContext): void { if (recordings.length === 0) { if (options.json === true) { - context.write('[]'); + // Through the Ui, exactly like the non-empty branch below, and + // NOT on the raw channel. Raw looks like it protects the machine + // contract, and does not: `PlainUi` is what runs whenever stdout + // is not a terminal, so a pipe receives a bare `[]` either way. + // What raw does do is print this one line at column 0 while the + // frame is drawn around it -- verified in a sized pty. One branch + // of one command must not render two different ways. + context.ui.content('[]'); return; } context.ui.emptyLibrary(); diff --git a/apps/cli/src/commands/mcp.ts b/apps/cli/src/commands/mcp.ts index f63efca..0c9effd 100644 --- a/apps/cli/src/commands/mcp.ts +++ b/apps/cli/src/commands/mcp.ts @@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import type { CliContext } from '../wiring.js'; import { buildMcpServer } from '../mcp/server.js'; import { registerMcpInstall } from './mcpInstall.js'; +import { VERSION } from '../version.js'; export function registerMcp(program: Command, context: CliContext): void { const mcp = program @@ -25,7 +26,7 @@ export function registerMcp(program: Command, context: CliContext): void { // channel. A single stray line of human-facing text would corrupt the // JSON-RPC stream and the client would drop the connection. Every other // command in this codebase writes through context.ui; this one must not. - const { server, close } = buildMcpServer(context, program.version() ?? '0.0.0'); + const { server, close } = buildMcpServer(context, program.version() ?? VERSION); const transport = new StdioServerTransport(); const shutdown = async (): Promise => { diff --git a/apps/cli/src/commands/mcpInstall.test.ts b/apps/cli/src/commands/mcpInstall.test.ts new file mode 100644 index 0000000..b19b2ff --- /dev/null +++ b/apps/cli/src/commands/mcpInstall.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { findAgent } from '../mcp/agents.js'; +import { allowShellAgents, directoryGrants, reportFile, resolveAllowShell } from './mcpInstall.js'; +import type { AgentOutcome, FileOutcome } from '../mcp/install.js'; +import type { CliContext } from '../wiring.js'; + +const claude = findAgent('claude')!; +const hermes = findAgent('hermes')!; + +describe('allowShellAgents', () => { + it('lists only the agents with an allow-list of their own', () => { + expect(allowShellAgents([claude, hermes])).toEqual([claude]); + }); +}); + +describe('resolveAllowShell', () => { + it('honours --allow-shell', async () => { + expect(await resolveAllowShell({ allowShell: true }, true, [claude])).toBe(true); + }); + + it('honours --no-allow-shell without prompting', async () => { + expect(await resolveAllowShell({ allowShell: false }, true, [claude])).toBe(false); + }); + + it('does not grant anything for -y on its own', async () => { + // --yes means "do not prompt". Resolving an unasked permission question + // as yes would widen an agent's privileges in CI on the strength of a + // flag that says nothing about permissions. + expect(await resolveAllowShell({ yes: true }, false, [claude])).toBe(false); + }); + + it('does not prompt when no chosen agent has an allow-list', async () => { + expect(await resolveAllowShell({}, true, [hermes])).toBe(false); + }); +}); + +/** A context that records which channel each line went to, and nothing else. */ +function uiSpy(): { calls: [string, string][]; context: CliContext } { + const calls: [string, string][] = []; + const push = + (channel: string) => + (message: string): void => { + calls.push([channel, message]); + }; + const ui = { success: push('success'), warn: push('warn'), note: push('note') }; + return { calls, context: { ui } as unknown as CliContext }; +} + +describe('reportFile', () => { + it('warns about a skipped allow-list, and says which refusal it was', () => { + // `skipped` means the user asked for something and did not get it. Said + // through `note`, it reads as "nothing needed doing" and gets scrolled + // past -- and the reason has to be the real one, because "not valid + // JSON" for a YAML file is a fault the user cannot find. + const { calls, context } = uiSpy(); + reportFile(context, { + path: '/home/ann/.codex/policy.yaml', + action: 'skipped', + detail: 'not valid YAML; left alone -- add the entry by hand', + }); + expect(calls).toHaveLength(1); + const [channel, message] = calls[0]!; + expect(channel).toBe('warn'); + expect(message).toContain('/home/ann/.codex/policy.yaml'); + expect(message).toContain('not valid YAML'); + expect(message).not.toContain('JSON'); + }); + + it('keeps the informational actions out of the warning channel', () => { + const { calls, context } = uiSpy(); + reportFile(context, { path: '/p/.mcp.json', action: 'unchanged' }); + reportFile(context, { path: '/p/.mcp.json', action: 'created' }); + expect(calls.map(([channel]) => channel)).toEqual(['note', 'success']); + }); +}); + +describe('directoryGrants', () => { + const HOME = '/home/ann'; + const CWD = '/work/repo'; + const copilot = findAgent('copilot')!; + const file = `${HOME}/.copilot/permissions-config.json`; + + const outcome = (files: FileOutcome[]): AgentOutcome => ({ + agent: copilot, + scope: 'global', + files, + note: '', + }); + + it('names the directory a Copilot grant is confined to', async () => { + // The file is machine-wide but the entry inside it is keyed by cwd, so + // the outcome row alone claims far more than was actually approved. + const lines = directoryGrants([outcome([{ path: file, action: 'created' }])], HOME, CWD); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(CWD); + }); + + it('says nothing when the allow-list was never written', async () => { + expect(directoryGrants([outcome([{ path: file, action: 'skipped' }])], HOME, CWD)).toEqual([]); + expect(directoryGrants([outcome([])], HOME, CWD)).toEqual([]); + }); + + it('says nothing for an agent whose grant is not keyed by directory', async () => { + const claudeOutcome: AgentOutcome = { + agent: claude, + scope: 'local', + files: [{ path: `${CWD}/.claude/settings.json`, action: 'created' }], + note: '', + }; + expect(directoryGrants([claudeOutcome], HOME, CWD)).toEqual([]); + }); +}); diff --git a/apps/cli/src/commands/mcpInstall.ts b/apps/cli/src/commands/mcpInstall.ts index 523fcfd..c467c41 100644 --- a/apps/cli/src/commands/mcpInstall.ts +++ b/apps/cli/src/commands/mcpInstall.ts @@ -1,4 +1,4 @@ -import { isCancel, multiselect, select } from '@clack/prompts'; +import { confirm, isCancel, multiselect, select } from '@clack/prompts'; import type { Command } from 'commander'; import { UsageError } from '@ailoud/core'; import type { CliContext } from '../wiring.js'; @@ -6,13 +6,17 @@ import { AGENTS, agentIds, findAgent, globalOnly } from '../mcp/agents.js'; import type { AgentTarget, Scope } from '../mcp/agents.js'; import { defaultHome } from '../mcp/agents.js'; import { detect, ensureProjectLibrary, install, uninstall, update } from '../mcp/install.js'; -import type { AgentOutcome } from '../mcp/install.js'; +import type { AgentOutcome, FileOutcome } from '../mcp/install.js'; import { isInteractive } from './setup.js'; +import { rememberProject } from '../projects.js'; +import { VERSION } from '../version.js'; -interface Options { +export interface Options { readonly target?: string; readonly location?: string; readonly yes?: boolean; + /** Set by --allow-shell, cleared by --no-allow-shell, absent when neither was given. */ + readonly allowShell?: boolean; } function parseScope(raw: string): Scope { @@ -95,15 +99,96 @@ async function askScope(agents: readonly AgentTarget[]): Promise { return parseScope(String(answer)); } +/** The chosen agents that have a command allow-list at all. */ +export function allowShellAgents(agents: readonly AgentTarget[]): readonly AgentTarget[] { + return agents.filter((agent) => agent.permission !== undefined); +} + +/** + * Whether to add `ailoud` to the chosen agents' allow-lists. + * + * `--yes` alone answers no. It means "do not prompt", and resolving a + * permission question nobody was asked as yes would widen an agent's + * privileges in CI on the strength of a flag that says nothing about + * permissions. `-y --allow-shell` is how to ask for it without a prompt. + */ +export async function resolveAllowShell( + options: Options, + interactive: boolean, + agents: readonly AgentTarget[], +): Promise { + if (options.allowShell !== undefined) return options.allowShell; + if (!interactive) return false; + if (allowShellAgents(agents).length === 0) return false; + const answer = await confirm({ + message: `Let these agents run "ailoud" without asking each time?`, + initialValue: true, + }); + if (isCancel(answer)) throw new UsageError('mcp install cancelled'); + return answer; +} + +/** + * One line for a file a `mcp install`/`uninstall`/`update` action touched (or + * left alone). `created` and `updated` actually changed something on disk, so + * they are marked as successes; `skipped` is a warning -- the user asked for + * the allow-list entry and did not get it; the rest -- `unchanged`, `removed`, + * `cleaned`, `absent` -- are informational: true, but not an achievement. + */ +export function reportFile(context: CliContext, file: FileOutcome): void { + const line = `${file.action.padEnd(9)} ${file.path}`; + if (file.action === 'created' || file.action === 'updated') { + context.ui.success(line); + } else if (file.action === 'skipped') { + // The user asked for something and did not get it, which is not the same + // as nothing needing doing. The detail comes from the writer, which knows + // which of its refusals this was; the line used to say "not valid JSON" + // for all of them, including a YAML file and a file that parsed fine. + context.ui.warn(file.detail === undefined ? line : `${line} (${file.detail})`); + } else { + context.ui.note(line); + } +} + +/** + * One line per agent whose grant covers only the directory it was made in. + * + * Copilot writes `locations[""]` into a machine-wide file, so its outcome + * row -- `created ~/.copilot/permissions-config.json` -- reads as approval for + * everything the user does. The pre-prompt listing would have said otherwise, + * but that listing is skipped whenever `--allow-shell` answered the question + * outright, so it is said here instead: on the outcome path, which every way + * of answering goes through. + * + * Only for an agent that actually got the entry. A `skipped` row means the + * file was left alone, and announcing a grant that was not made is worse than + * saying nothing. + */ +export function directoryGrants( + outcomes: readonly AgentOutcome[], + home: string, + cwd: string, +): readonly string[] { + const lines: string[] = []; + for (const outcome of outcomes) { + const permission = outcome.agent.permission; + if (permission?.directoryScoped !== true) continue; + const path = permission.path(outcome.scope, home, cwd); + const granted = outcome.files.some((file) => file.path === path && file.action !== 'skipped'); + if (granted) { + lines.push(`${outcome.agent.label} approves "ailoud" in ${cwd} only, not machine-wide.`); + } + } + return lines; +} + /** One line per file touched, so the user can see exactly what changed. */ function report(context: CliContext, outcomes: readonly AgentOutcome[]): void { for (const outcome of outcomes) { - for (const file of outcome.files) { - context.write(`${file.action.padEnd(9)} ${file.path}`); - } + for (const file of outcome.files) reportFile(context, file); } const notes = [...new Set(outcomes.map((outcome) => outcome.note))]; - for (const note of notes) context.write(`note: ${note}`); + for (const note of notes) context.ui.note(`note: ${note}`); } /** @@ -112,6 +197,30 @@ function report(context: CliContext, outcomes: readonly AgentOutcome[]): void { * Silently installing a global-only agent globally while the user asked for * "this project only" would be a surprise; saying so is not. */ +/** + * Records the project a successful `mcp install` just wrote rules into, with + * this build's version -- that is what lets a later `ailoud self sync` say + * "current" for it instead of rewriting bytes that have not changed. + * + * Registration is bookkeeping, not the user's request: a full disk, a + * read-only project directory, or any other write failure here must never + * fail an install that otherwise succeeded. Any error is swallowed down to a + * single debug line. + */ +async function registerAfterInstall(context: CliContext, cwd: string): Promise { + try { + await rememberProject( + { fs: context.fs, clock: context.clock, userDataDir: context.paths.userDataDir }, + { path: cwd, rulesVersion: VERSION }, + ); + } catch (error) { + process.stderr.write( + `ailoud: debug: could not register project "${cwd}": ` + + `${error instanceof Error ? error.message : String(error)}\n`, + ); + } +} + function splitByScope( agents: readonly AgentTarget[], scope: Scope, @@ -133,6 +242,8 @@ export function registerMcpInstall(parent: Command, context: CliContext): void { .option('-t, --target ', `comma-separated agent ids, or "auto" or "all": ${agentIds()}`) .option('-l, --location ', '"global" or "local"') .option('-y, --yes', 'no prompts: --location=global --target=auto') + .option('--allow-shell', 'pre-approve running "ailoud" in the agents\' allow-lists') + .option('--no-allow-shell', "do not touch the agents' allow-lists") .action(async (options: Options) => { await context.ui.frame('Installing MCP', async () => { const interactive = @@ -146,8 +257,8 @@ export function registerMcpInstall(parent: Command, context: CliContext): void { : await parseTargets(context, 'auto', home()); if (agents.length === 0) { - context.write('No agents selected, so nothing was configured.'); - context.write(`Run again with --target to name one: ${agentIds()}`); + context.ui.warn('No agents selected, so nothing was configured.'); + context.ui.warn(`Run again with --target to name one: ${agentIds()}`); return; } @@ -165,17 +276,43 @@ export function registerMcpInstall(parent: Command, context: CliContext): void { // project rather than joining the per-user collection. if (scope === 'local' && inScope.length > 0) { const library = await ensureProjectLibrary(context.fs, cwd()); - context.write(`${library.action.padEnd(9)} ${library.path}`); + reportFile(context, library); } + // Both the listing below and the question resolveAllowShell asks + // must agree on which agents are involved, so both read from this + // one call rather than filtering the chosen agents twice. + const chosenAgents = [...inScope, ...forcedGlobal]; + const permissioned = allowShellAgents(chosenAgents); + + // The exact files and entries, before the question rather than after + // it: "allow ailoud" is not something a user can weigh without + // knowing which of their configuration files it edits. + if (options.allowShell === undefined && interactive && permissioned.length > 0) { + context.ui.note('The allow-list entry would be added to:'); + for (const agent of permissioned) { + const at = agent.scopes.includes(scope) ? scope : 'global'; + context.ui.note(` ${agent.label}: ${agent.permission!.path(at, home(), cwd())}`); + } + } + const allowShell = await resolveAllowShell(options, interactive, chosenAgents); + for (const agent of inScope) { - outcomes.push(await install(context.fs, agent, scope, home(), cwd())); + outcomes.push(await install(context.fs, agent, scope, home(), cwd(), allowShell)); } for (const agent of forcedGlobal) { - context.write(`${agent.label} reads no per-project config; configuring it globally.`); - outcomes.push(await install(context.fs, agent, 'global', home(), cwd())); + context.ui.note(`${agent.label} reads no per-project config; configuring it globally.`); + outcomes.push(await install(context.fs, agent, 'global', home(), cwd(), allowShell)); + } + + // Only once rules were actually written locally: a run that only + // touched global-only agents wrote nothing into this project, and + // has nothing to register. + if (scope === 'local' && inScope.length > 0) { + await registerAfterInstall(context, cwd()); } + for (const line of directoryGrants(outcomes, home(), cwd())) context.ui.note(line); report(context, outcomes); }); }); @@ -213,11 +350,11 @@ export function registerMcpInstall(parent: Command, context: CliContext): void { if (touched.length === 0) { // Said plainly rather than reported as a success: an uninstall that // claims to have cleaned files it never touched teaches distrust. - context.write('Nothing to remove: no agent here was configured for AILoud.'); + context.ui.warn('Nothing to remove: no agent here was configured for AILoud.'); return; } report(context, outcomes); - context.write( + context.ui.note( 'The .ailoud/ library directory was left alone; delete it by hand if you want it gone.', ); }); @@ -240,8 +377,8 @@ export function registerMcpInstall(parent: Command, context: CliContext): void { } } if (outcomes.length === 0) { - context.write('Nothing to update: no agent here is configured for AILoud.'); - context.write('Run "ailoud mcp install" first.'); + context.ui.warn('Nothing to update: no agent here is configured for AILoud.'); + context.ui.warn('Run "ailoud mcp install" first.'); return; } report(context, outcomes); diff --git a/apps/cli/src/commands/reports.test.ts b/apps/cli/src/commands/reports.test.ts index 415d0e8..d0a5598 100644 --- a/apps/cli/src/commands/reports.test.ts +++ b/apps/cli/src/commands/reports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { FailureError, UsageError } from '@ailoud/core'; +import { UsageError } from '@ailoud/core'; import type { Summary } from '@ailoud/core'; import { buildProgram } from '../program.js'; import { FIXTURE_PATH, contextWithTranscript } from './testContext.js'; @@ -52,14 +52,37 @@ describe('reportPreview', () => { }); describe('ailoud report ls / show', () => { - it('says so when there are none, rather than printing an empty table', async () => { + it('says so when there are none, and exits 0 rather than failing', async () => { + // Not a failure. `ls` on an empty library, `ls --tag` on a filter that + // matches nothing, and `self sync` with no projects all exit 0, and + // `report ls --json` already exited 0 on this very state -- so the text + // and JSON forms of one command used to disagree. A script cannot tell an + // empty list from a real failure if both exit non-zero. const ctx = await contextWithTranscript({ clearLines: true }); - await expect(buildProgram(ctx).parseAsync(['node', 'ailoud', 'report', 'ls'])).rejects.toThrow( - FailureError, - ); - await expect(buildProgram(ctx).parseAsync(['node', 'ailoud', 'report', 'ls'])).rejects.toThrow( - /No reports yet/, - ); + + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'report', 'ls']), + ).resolves.toBeDefined(); + + expect(ctx.lines.join('\n')).toMatch(/No reports yet/); + }); + + it('exits 0 when a named recording has no reports', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + const [recording] = await ctx.store.listRecordings({}); + + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'report', + 'ls', + '--recording', + recording!.id, + ]), + ).resolves.toBeDefined(); + + expect(ctx.lines.join('\n')).toMatch(/No reports cover/); }); it('lists what produced each report, newest first', async () => { @@ -269,16 +292,64 @@ describe('command layout', () => { return hidden !== true; }) .map((command) => command.name()); - expect(visible).toEqual(['audio', 'report', 'template', 'mcp', 'doctor', 'setup']); + expect(visible).toEqual([ + 'audio', + 'report', + 'job', + 'template', + 'mcp', + 'doctor', + 'setup', + 'self', + ]); }); it('gives every second-level verb a one-letter alias, none colliding', async () => { // Collision is the risk a single table exists to make visible. const ctx = await contextWithTranscript({ skipImport: true }); - for (const groupName of ['audio', 'report']) { + // EVERY group, discovered from the program rather than listed here: the + // old version named `audio` and `report` only, so a collision inside + // `self` or `template` -- the two groups added since -- would have gone + // unnoticed. Discovering them means a group added later is covered the + // day it appears. + const groups = buildProgram(ctx).commands.filter( + (command) => command.commands.length > 0 && command.name() !== 'help', + ); + // A group either assigns letters to ALL its verbs or to none. `mcp` is + // the deliberate none -- `uninstall` and `update` both want `u`, so the + // set cannot be made unique, and a half-assigned set is worse than no + // set. See `attachLetters` in groups.js. + const lettered = groups.filter((command) => + command.commands.some((verb) => verb.name() !== 'help' && verb.aliases().length > 0), + ); + const unlettered = groups.filter((command) => !lettered.includes(command)); + expect(lettered.map((command) => command.name()).sort()).toEqual([ + 'audio', + 'job', + 'report', + 'self', + 'template', + ]); + expect(unlettered.map((command) => command.name())).toEqual(['mcp']); + + // A second-level command that has subcommands of its own is a sub-group, + // not a verb: `self completions` is never typed alone, only ever + // `self completions install`. A letter for the group node would be worth + // nothing and would eat one of the group's few free letters -- `self` + // already spends c, u and s on check, update and sync. Sub-groups are + // therefore exempt from needing a letter, and the assertion below pins + // which commands took that exemption so one cannot appear unnoticed. + const subGroups = lettered.flatMap((command) => + command.commands + .filter((verb) => verb.name() !== 'help' && verb.commands.length > 0) + .map((verb) => `${command.name()} ${verb.name()}`), + ); + expect(subGroups).toEqual(['self completions']); + + for (const groupName of lettered.map((command) => command.name())) { const found = buildProgram(ctx).commands.find((c) => c.name() === groupName)!; const letters = found.commands - .filter((command) => command.name() !== 'help') + .filter((command) => command.name() !== 'help' && command.commands.length === 0) .map((command) => command.aliases()[0]); expect(letters, groupName).not.toContain(undefined); expect(new Set(letters).size, groupName).toBe(letters.length); diff --git a/apps/cli/src/commands/reports.ts b/apps/cli/src/commands/reports.ts index 564afa2..61012f3 100644 --- a/apps/cli/src/commands/reports.ts +++ b/apps/cli/src/commands/reports.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { FailureError, formatRecordedAt } from '@ailoud/core'; +import { formatRecordedAt } from '@ailoud/core'; import type { Summary } from '@ailoud/core'; import { page, shouldPage } from '@ailoud/providers'; import type { CliContext } from '../wiring.js'; @@ -112,11 +112,19 @@ export function registerReports(parent: Command, context: CliContext): void { context.ui.content('[]'); return; } - throw new FailureError( + // Exit 0, not a failure. "Nothing here yet" is the same answer + // `ls` gives for an empty library, `ls --tag` for a filter that + // matches nothing, and `self sync` for no registered projects -- + // and `report ls --json` already exited 0 on this very state, so + // the text and JSON forms of one command disagreed. A script that + // treats an empty list as an error cannot tell it from a real + // failure. + context.ui.note( options.recording === undefined ? 'No reports yet. Run "ailoud summarize " to make one.' : `No reports cover ${options.recording}.`, ); + return; } if (options.json === true) { @@ -153,20 +161,20 @@ export function registerReports(parent: Command, context: CliContext): void { const summaries: Summary[] = []; for (const id of ids) summaries.push(await resolveSummary(context.store, id)); - context.write( + context.ui.content( summaries.length === 1 ? 'This will permanently delete 1 report:' : `This will permanently delete ${summaries.length} reports:`, ); for (const summary of summaries) { - context.write( + context.ui.content( ` ${summary.id} ${formatRecordedAt(summary.createdAt)} ${summary.model} ` + reportPreview(summary.body, 40), ); } // The recordings and transcripts stay: a report is derived, and what it // was derived from is the library itself. - context.write('The recordings and their transcripts are not touched.'); + context.ui.note('The recordings and their transcripts are not touched.'); // The same guard rm and setup use, for the same reason: one question, // one answer, and the same refusal with no terminal so a script cannot @@ -182,13 +190,18 @@ export function registerReports(parent: Command, context: CliContext): void { consentFlag: '--force', }); if (!consented) { - context.write('Nothing was deleted.'); + context.ui.warn('Nothing was deleted.'); return; } for (const summary of summaries) { const deleted = await context.store.deleteSummary(summary.id); - context.write(`${summary.id} ${deleted ? 'deleted' : 'was already gone'}`); + const line = `${summary.id} ${deleted ? 'deleted' : 'was already gone'}`; + if (deleted) { + context.ui.success(line); + } else { + context.ui.note(line); + } } }); }); diff --git a/apps/cli/src/commands/resourceOptions.test.ts b/apps/cli/src/commands/resourceOptions.test.ts new file mode 100644 index 0000000..3b2c750 --- /dev/null +++ b/apps/cli/src/commands/resourceOptions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { UsageError } from '@ailoud/core'; +import { parseDenoise, parseMaxCpu } from './resourceOptions.js'; + +describe('parseMaxCpu', () => { + it('round-trips a percent inside the range', () => { + expect(parseMaxCpu('1')).toBe(1); + expect(parseMaxCpu('50')).toBe(50); + expect(parseMaxCpu('100')).toBe(100); + }); + + it.each(['0', '101', 'abc', '-5', '2.5'])('refuses %s, naming the accepted range', (value) => { + expect(() => parseMaxCpu(value)).toThrow(UsageError); + expect(() => parseMaxCpu(value)).toThrow(/1 to 100/); + }); +}); + +describe('parseDenoise', () => { + it.each(['auto', 'on', 'off'] as const)('round-trips %s', (mode) => { + expect(parseDenoise(mode)).toBe(mode); + }); + + it('refuses an unknown mode, naming the three accepted ones', () => { + expect(() => parseDenoise('sometimes')).toThrow(UsageError); + expect(() => parseDenoise('sometimes')).toThrow(/auto/); + expect(() => parseDenoise('sometimes')).toThrow(/\bon\b/); + expect(() => parseDenoise('sometimes')).toThrow(/off/); + }); +}); diff --git a/apps/cli/src/commands/resourceOptions.ts b/apps/cli/src/commands/resourceOptions.ts new file mode 100644 index 0000000..982ae18 --- /dev/null +++ b/apps/cli/src/commands/resourceOptions.ts @@ -0,0 +1,33 @@ +import { DENOISE_MODES, UsageError } from '@ailoud/core'; +import type { DenoiseMode } from '@ailoud/core'; + +/** + * Parses `--max-cpu`, shared by `transcribe` and `summarize` since both spawn + * engines that read a thread budget. + * + * Validated here rather than left to the budget's own clamping, because this + * is the boundary where a user's mistake should be told to them. The budget + * silently falls back to the default instead, which is right for a library + * caller and wrong for someone who typed a number. + */ +export function parseMaxCpu(value: string): number { + const percent = Number(value); + if (!Number.isInteger(percent) || percent < 1 || percent > 100) { + throw new UsageError( + `--max-cpu takes a whole number of percent from 1 to 100, not "${value}".`, + ); + } + return percent; +} + +/** + * Parses `--denoise`. `transcribe` only: summarize reads stored transcripts, + * not audio, so it has nothing to denoise. + */ +export function parseDenoise(value: string): DenoiseMode { + const found = DENOISE_MODES.find((mode) => mode === value); + if (found === undefined) { + throw new UsageError(`--denoise takes ${DENOISE_MODES.join(', ')}, not "${value}".`); + } + return found; +} diff --git a/apps/cli/src/commands/rm.ts b/apps/cli/src/commands/rm.ts index 3299667..d13d74a 100644 --- a/apps/cli/src/commands/rm.ts +++ b/apps/cli/src/commands/rm.ts @@ -43,7 +43,7 @@ export function registerRm(program: Command, context: CliContext): void { // ids must not leave the first two gone, and there is no undo. const recordings = await resolveRecordings(context.store, ids); - for (const line of describeDeletion(recordings)) context.write(line); + for (const line of describeDeletion(recordings)) context.ui.content(line); // Reuses setup's guard rather than writing a second one: same // question (may this run change things without being asked?), same @@ -59,7 +59,7 @@ export function registerRm(program: Command, context: CliContext): void { consentFlag: '--force', }); if (!consented) { - context.write('Nothing was deleted.'); + context.ui.warn('Nothing was deleted.'); return; } diff --git a/apps/cli/src/commands/self.test.ts b/apps/cli/src/commands/self.test.ts new file mode 100644 index 0000000..a110c67 --- /dev/null +++ b/apps/cli/src/commands/self.test.ts @@ -0,0 +1,1096 @@ +import { describe, expect, it } from 'vitest'; +import { FailureError, UsageError } from '@ailoud/core'; +import type { PublishedVersion, VersionSource } from '@ailoud/core'; +import { MemFs, FakeClock } from '@ailoud/core/testing'; +import type { RunOptions, RunResult } from '@ailoud/providers'; +import { buildProgram, exitCodeFor } from '../program.js'; +import { context } from './testContext.js'; +import { findAgent } from '../mcp/agents.js'; +import { install } from '../mcp/install.js'; +import { pruneProjects, readProjects, rememberProject } from '../projects.js'; +import type { SyncDeps } from './self.js'; +import { boundedDetectRun, syncProjects, updateSelf } from './self.js'; +import type { SelfUpdateDeps } from './self.js'; +import { updateLogPath } from '../updateLog.js'; +import { VERSION } from '../version.js'; +import { install as installCompletions } from '../completions/install.js'; +import { findShell } from '../completions/shells.js'; +import type { CommandNode } from '../completions/generate.js'; + +/** + * A minimal command tree for the completions writers below. Its content is + * never asserted on -- only that a script got written and then rewritten -- + * so it does not need to describe the real CLI. + */ +const COMPLETIONS_TREE: CommandNode = { + name: 'ailoud', + description: 'root', + aliases: [], + options: [], + children: [], +}; + +/** + * The build's own version, and one patch above it. + * + * Derived rather than written down: these tests assert what `ailoud` says + * about ITSELF, so a literal `1.0.0` made every one of them fail the moment + * the version was bumped for a release -- and a literal `1.0.1` offered as + * "newer" became OLDER than the build at 1.1.0, which is how a fixture stops + * testing what it claims. `NEWER` strips any pre-release suffix before + * bumping, so it stays above `VERSION` on a snapshot build too. + */ +const NEWER = (() => { + const [major, minor, patch] = VERSION.split('-')[0]!.split('.'); + return `${major}.${minor}.${Number(patch) + 1}`; +})(); + +/** A VersionSource that answers with a fixed list, never touching the network. */ +function source(published: readonly PublishedVersion[]): VersionSource { + return { published: async () => published }; +} + +describe('ailoud self check', () => { + it('reports the target it would move to', async () => { + const ctx = { ...context(), versionSource: source([{ version: NEWER, deprecated: false }]) }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'check']); + expect(ctx.lines).toEqual([`ailoud ${VERSION} can update to ${NEWER}.`]); + }); + + it('says so when there is nothing newer', async () => { + const ctx = { ...context(), versionSource: source([{ version: VERSION, deprecated: false }]) }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'check']); + expect(ctx.lines).toEqual([`ailoud ${VERSION} is already the newest published version.`]); + // A version check is not a test: it must exit 0 either way. + }); + + it('prints JSON with --json', async () => { + const ctx = { ...context(), versionSource: source([{ version: NEWER, deprecated: false }]) }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'check', '--json']); + expect(JSON.parse(ctx.lines.join(''))).toEqual({ + current: VERSION, + target: NEWER, + updatable: true, + }); + }); + + it('prints JSON with no target when there is nothing newer', async () => { + const ctx = { ...context(), versionSource: source([{ version: VERSION, deprecated: false }]) }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'check', '--json']); + expect(JSON.parse(ctx.lines.join(''))).toEqual({ + current: VERSION, + target: null, + updatable: false, + }); + }); + + it('fails with the host and the timeout when the registry is unreachable', async () => { + const ctx = { + ...context(), + updateRegistryHost: 'registry.npmjs.org', + updateTimeoutMs: 10_000, + versionSource: { + published: async (): Promise => { + throw new Error('fetch failed'); + }, + }, + }; + // Exit 1: an explicit check that could not run must not read as "up to date". + await expect(buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'check'])).rejects.toThrow( + FailureError, + ); + const error: unknown = await buildProgram(ctx) + .parseAsync(['node', 'ailoud', 'self', 'check']) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(FailureError); + expect((error as Error).message).toContain('registry.npmjs.org'); + // NOT asserting a timeout here: "fetch failed" is a refused connection or + // a bad name, and calling it a timeout sends the user to the wrong place. + // The underlying reason has to survive instead. + expect((error as Error).message).toContain('fetch failed'); + expect((error as Error).message).not.toContain('timed out'); + expect(exitCodeFor(error)).toBe(1); + }); + + it('exists as a hidden top-level alias, "ailoud check"', async () => { + const ctx = { ...context(), versionSource: source([{ version: VERSION, deprecated: false }]) }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'check']); + expect(ctx.lines).toEqual([`ailoud ${VERSION} is already the newest published version.`]); + }); + + it('answers to its one-letter alias inside the group', async () => { + const ctx = { ...context(), versionSource: source([{ version: VERSION, deprecated: false }]) }; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'c']); + expect(ctx.lines).toEqual([`ailoud ${VERSION} is already the newest published version.`]); + }); +}); + +describe('syncProjects', () => { + const USER_DATA_DIR = '/data/ailoud'; + const HOME = '/home/user'; + const claude = findAgent('claude')!; + + function deps(fs: MemFs): SyncDeps { + return { fs, clock: new FakeClock(), userDataDir: USER_DATA_DIR, home: HOME }; + } + + /** Rewrites a project's CLAUDE.md so it no longer matches the current build's block. */ + async function makeStale(fs: MemFs, projectPath: string): Promise { + const rulesPath = `${projectPath}/.claude/CLAUDE.md`; + const current = await fs.readTextFile(rulesPath); + const stale = current.replace('## AILoud', '## AILoud (text from an older ailoud build)'); + await fs.writeTextFile(rulesPath, stale); + } + + it('refreshes a rules block and reports it as refreshed', async () => { + const fs = new MemFs({}); + await install(fs, claude, 'local', HOME, '/proj/a', false); + const rulesPath = '/proj/a/.claude/CLAUDE.md'; + const current = await fs.readTextFile(rulesPath); + await makeStale(fs, '/proj/a'); + + const d = deps(fs); + await rememberProject(d, { path: '/proj/a' }); + + const report = await syncProjects(d); + + expect(report.rows).toEqual([{ path: '/proj/a', status: 'refreshed' }]); + expect(report.failed).toBe(false); + // Rewritten back to exactly the bytes the current build would have + // written on a fresh install -- update() is idempotent by construction. + expect(await fs.readTextFile(rulesPath)).toBe(current); + }); + + it('reports a project whose block is already current, without writing', async () => { + class LoggingFs extends MemFs { + readonly writes: string[] = []; + override async writeTextFile(path: string, content: string): Promise { + this.writes.push(path); + return super.writeTextFile(path, content); + } + } + const fs = new LoggingFs({}); + await install(fs, claude, 'local', HOME, '/proj/a', false); + + const d = deps(fs); + await rememberProject(d, { path: '/proj/a' }); + const writesBeforeSync = fs.writes.length; + + const report = await syncProjects(d); + + expect(report.rows).toEqual([{ path: '/proj/a', status: 'current' }]); + expect(report.failed).toBe(false); + // Only bookkeeping (the registry, the log) may be written from here on; + // the project's own rules/config files must be untouched because + // update() already found them byte-identical to the current build. + const newWrites = fs.writes.slice(writesBeforeSync); + expect(newWrites).not.toContain('/proj/a/.claude/CLAUDE.md'); + expect(newWrites).not.toContain('/proj/a/.mcp.json'); + }); + + it('reports a project with no rules block as such', async () => { + const fs = new MemFs({}); + fs.dirs.add('/proj/empty'); // the directory exists; ailoud was just never installed into it + + const d = deps(fs); + await rememberProject(d, { path: '/proj/empty' }); + + const report = await syncProjects(d); + + expect(report.rows).toEqual([{ path: '/proj/empty', status: 'no rules here' }]); + expect(report.failed).toBe(false); + }); + + it('continues after one project fails, and exits non-zero', async () => { + // Throws only once armed, so seeding the fixture (which itself writes + // through this same Fs) is not what trips the failure. + class FlakyFs extends MemFs { + armed = false; + override async writeTextFile(path: string, content: string): Promise { + // `includes`, not `===`: the rules file is written to + // `..tmp` and renamed over the target, so matching the + // target exactly would stop injecting the fault altogether and leave + // this test quietly asserting the happy path. + if (this.armed && path.includes('/proj/b/.claude/CLAUDE.md')) { + throw new Error('EACCES: permission denied'); + } + return super.writeTextFile(path, content); + } + } + const fs = new FlakyFs({}); + await install(fs, claude, 'local', HOME, '/proj/a', false); + await install(fs, claude, 'local', HOME, '/proj/b', false); + await makeStale(fs, '/proj/a'); + await makeStale(fs, '/proj/b'); + fs.armed = true; + + const d = deps(fs); + await rememberProject(d, { path: '/proj/a' }); + await rememberProject(d, { path: '/proj/b' }); + + const report = await syncProjects(d); + + expect(report.failed).toBe(true); + const byPath = new Map(report.rows.map((row) => [row.path, row.status])); + // The other nineteen (here: the other one) still get refreshed. + expect(byPath.get('/proj/a')).toBe('refreshed'); + expect(byPath.get('/proj/b')).toMatch(/^failed: /); + expect(byPath.get('/proj/b')).toContain('permission denied'); + }); + + it('prunes a project whose directory is gone', async () => { + const fs = new MemFs({}); + const d = deps(fs); + await rememberProject(d, { path: '/proj/gone' }); + // '/proj/gone' is deliberately never added to fs.dirs. + + const report = await syncProjects(d); + + expect(report.rows).toEqual([{ path: '/proj/gone', status: 'gone' }]); + expect(report.failed).toBe(false); + expect(await readProjects(d)).toEqual([]); + }); + + it('records rulesVersion so the next sync can say "current"', async () => { + const fs = new MemFs({}); + await install(fs, claude, 'local', HOME, '/proj/a', false); + await makeStale(fs, '/proj/a'); + + const d = deps(fs); + await rememberProject(d, { path: '/proj/a' }); + + const first = await syncProjects(d); + expect(first.rows).toEqual([{ path: '/proj/a', status: 'refreshed' }]); + + const [entry] = await readProjects(d); + expect(entry?.rulesVersion).toBe(VERSION); + + // Nothing changed the second time: the rows come from update()'s own + // byte comparison, not from re-reading rulesVersion, but recording it is + // what a caller (a future "self status") would use to explain why. + const second = await syncProjects(d); + expect(second.rows).toEqual([{ path: '/proj/a', status: 'current' }]); + }); + + it('appends one line per run to the update log', async () => { + const fs = new MemFs({}); + await install(fs, claude, 'local', HOME, '/proj/a', false); + fs.dirs.add('/proj/empty'); + + const d = deps(fs); + await rememberProject(d, { path: '/proj/a' }); + await rememberProject(d, { path: '/proj/empty' }); + + await syncProjects(d); + + const log = await fs.readTextFile(updateLogPath(USER_DATA_DIR)); + const lines = log.split('\n').filter((line) => line.length > 0); + expect(lines).toHaveLength(2); // one line per project row this run produced + expect(lines.some((line) => line.includes('/proj/a'))).toBe(true); + expect(lines.some((line) => line.includes('/proj/empty'))).toBe(true); + }); + + it('caps the log rather than growing it forever', async () => { + const fs = new MemFs({}); + fs.dirs.add('/proj/a'); + const seedLines = Array.from({ length: 20_000 }, (_, i) => `old line ${i} ${'x'.repeat(50)}`); + await fs.ensureDir(USER_DATA_DIR); + await fs.writeTextFile(updateLogPath(USER_DATA_DIR), `${seedLines.join('\n')}\n`); + + const d = deps(fs); + await rememberProject(d, { path: '/proj/a' }); + + await syncProjects(d); + + const log = await fs.readTextFile(updateLogPath(USER_DATA_DIR)); + const lines = log.split('\n').filter((line) => line.length > 0); + expect(lines.length).toBeLessThanOrEqual(500); + expect(lines[lines.length - 1]).toContain('/proj/a'); // the newest action is never lost + }); +}); + +describe('ailoud self sync (CLI)', () => { + it('says so when no project is registered', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'sync']); + expect(ctx.lines).toEqual(['No projects registered yet.']); + }); + + it('answers to its one-letter alias inside the group', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 's']); + expect(ctx.lines).toEqual(['No projects registered yet.']); + }); + + it('exists as a hidden top-level alias, "ailoud sync"', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'sync']); + expect(ctx.lines).toEqual(['No projects registered yet.']); + }); + + it('prints one row per project and exits non-zero when one failed', async () => { + class FlakyFs extends MemFs { + armed = false; + override async writeTextFile(path: string, content: string): Promise { + // `includes`, not `===`: the rules file is written to + // `..tmp` and renamed over the target, so matching the + // target exactly would stop injecting the fault altogether and leave + // this test quietly asserting the happy path. + if (this.armed && path.includes('/proj/a/.claude/CLAUDE.md')) { + throw new Error('EACCES: permission denied'); + } + return super.writeTextFile(path, content); + } + } + const fs = new FlakyFs({}); + const claude = findAgent('claude')!; + await install(fs, claude, 'local', '/home/user', '/proj/a', false); + const rulesPath = '/proj/a/.claude/CLAUDE.md'; + const current = await fs.readTextFile(rulesPath); + await fs.writeTextFile(rulesPath, current.replace('## AILoud', '## AILoud (old)')); + fs.armed = true; + + const ctx = { ...context(), fs }; + await rememberProject( + { fs, clock: ctx.clock, userDataDir: ctx.paths.userDataDir }, + { path: '/proj/a' }, + ); + + const error: unknown = await buildProgram(ctx) + .parseAsync(['node', 'ailoud', 'self', 'sync']) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(FailureError); + expect(exitCodeFor(error)).toBe(1); + // The row now carries the "warning: " marker `ui.warn` adds for a + // `failed:` status (see registerSelfSync), so the assertion looks for + // that prefix rather than the bare status word. + expect( + ctx.lines.some((line) => line.startsWith('warning: failed:') && line.includes('/proj/a')), + ).toBe(true); + }); + + // placesFor (in selfCompletions.ts) reads $HOME straight from process.env, + // not from CliContext.paths, so the three tests below have to set it for + // the duration of the test -- there is no other seam to point the + // completions writers at the fake filesystem's home directory. + function withHome(home: string, run: () => Promise): Promise { + const original = process.env['HOME']; + process.env['HOME'] = home; + return run().finally(() => { + if (original === undefined) delete process.env['HOME']; + else process.env['HOME'] = original; + }); + } + + it('refreshes an installed shell completions even on the empty-registry early return', async () => { + // This is the riskiest property in the sync/completions integration: an + // empty project registry takes registerSelfSync's early `return` before + // any row is ever printed, and a `syncCompletions` call placed after that + // return would never run for a user with no registered projects at all. + await withHome('/home/ann', async () => { + const ctx = context(); + const places = { + home: '/home/ann', + configHome: ctx.paths.configHome, + userDataDir: ctx.paths.userDataDir, + }; + await installCompletions(ctx.fs, findShell('zsh')!, COMPLETIONS_TREE, places); + const scriptPath = `${ctx.paths.userDataDir}/completions/_ailoud`; + // Corrupt the already-installed script so a refresh is the only thing + // that can put the real content back -- proving the write actually ran + // rather than merely that the file already existed. + await ctx.fs.writeTextFile(scriptPath, '# stale\n'); + + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'sync']); + + expect(ctx.lines).toContain('No projects registered yet.'); + expect(await ctx.fs.readTextFile(scriptPath)).not.toContain('stale'); + expect( + ctx.lines.some((line) => line.startsWith('ok updated') && line.includes(scriptPath)), + ).toBe(true); + }); + }); + + it('refreshes completions before throwing when a project failed to sync', async () => { + // The throw for a failed project happens at the very end of the action, + // after syncCompletions has already run -- but only the CODE says so. + // This proves it by observing the refresh's effect survives the throw. + await withHome('/home/ann', async () => { + class FlakyFs extends MemFs { + armed = false; + override async writeTextFile(path: string, content: string): Promise { + if (this.armed && path.includes('/proj/a/.claude/CLAUDE.md')) { + throw new Error('EACCES: permission denied'); + } + return super.writeTextFile(path, content); + } + } + const fs = new FlakyFs({}); + const claude = findAgent('claude')!; + await install(fs, claude, 'local', '/home/user', '/proj/a', false); + const rulesPath = '/proj/a/.claude/CLAUDE.md'; + const current = await fs.readTextFile(rulesPath); + await fs.writeTextFile(rulesPath, current.replace('## AILoud', '## AILoud (old)')); + + const ctx = { ...context(), fs }; + const places = { + home: '/home/ann', + configHome: ctx.paths.configHome, + userDataDir: ctx.paths.userDataDir, + }; + await installCompletions(fs, findShell('zsh')!, COMPLETIONS_TREE, places); + const scriptPath = `${ctx.paths.userDataDir}/completions/_ailoud`; + await fs.writeTextFile(scriptPath, '# stale\n'); + + await rememberProject( + { fs, clock: ctx.clock, userDataDir: ctx.paths.userDataDir }, + { path: '/proj/a' }, + ); + fs.armed = true; + + const error: unknown = await buildProgram(ctx) + .parseAsync(['node', 'ailoud', 'self', 'sync']) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(FailureError); + expect(await fs.readTextFile(scriptPath)).not.toContain('stale'); + expect( + ctx.lines.some((line) => line.startsWith('ok updated') && line.includes(scriptPath)), + ).toBe(true); + }); + }); + + it('reports a failing refresh as a warning, and still reports the projects', async () => { + await withHome('/home/ann', async () => { + class FlakyFs extends MemFs { + armed = false; + override async writeTextFile(path: string, content: string): Promise { + // The script write, not the rc write: it happens first inside + // install(), so arming this is enough to make the whole refresh + // throw without needing to know install()'s internal order. + if (this.armed && path.includes('/completions/')) { + throw new Error('ENOSPC: no space left on device'); + } + return super.writeTextFile(path, content); + } + } + const fs = new FlakyFs({}); + const ctx = { ...context(), fs }; + const places = { + home: '/home/ann', + configHome: ctx.paths.configHome, + userDataDir: ctx.paths.userDataDir, + }; + await installCompletions(fs, findShell('zsh')!, COMPLETIONS_TREE, places); + fs.armed = true; + + // Must resolve, not reject: a completions refresh is a convenience on + // top of the sync, and turning its failure into a thrown FailureError + // would tell the user their sync did not happen when it did. + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'sync']); + + expect(ctx.lines).toContain('No projects registered yet.'); + expect( + ctx.lines.some((line) => line.startsWith('warning: could not refresh shell completions')), + ).toBe(true); + }); + }); + + it('surfaces the bash_profile advisory on the sync path too, not only from "self completions"', async () => { + // Minor finding: syncCompletions used to report only the file lines and + // silently drop outcome.note, so a user whose completions were refreshed + // automatically after `self update` never learned their macOS login + // shell does not read ~/.bashrc -- the exact advisory the explicit + // `self completions install/update` commands already show via report(). + await withHome('/home/ann', async () => { + const ctx = context(); + const places = { + home: '/home/ann', + configHome: ctx.paths.configHome, + userDataDir: ctx.paths.userDataDir, + }; + // Present but not sourcing .bashrc: exactly what ShellTarget.warnAbout + // (bashWarnAbout in completions/shells.ts) looks for. + await ctx.fs.writeTextFile('/home/ann/.bash_profile', 'export PATH=x\n'); + await installCompletions(ctx.fs, findShell('bash')!, COMPLETIONS_TREE, places); + + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'sync']); + + expect(ctx.lines).toContain('No projects registered yet.'); + expect(ctx.lines.some((line) => line.startsWith('warning: ~/.bash_profile exists'))).toBe( + true, + ); + }); + }); +}); + +describe('boundedDetectRun', () => { + it('gives detectInstallMethod a run bounded to 10 seconds', async () => { + const seen: Array<{ command: string; args: readonly string[]; options?: RunOptions }> = []; + const fakeRunImpl = async ( + command: string, + args: readonly string[], + options?: RunOptions, + ): Promise => { + seen.push({ command, args, ...(options === undefined ? {} : { options }) }); + return { code: 0, stdout: '', stderr: '' }; + }; + + const bounded = boundedDetectRun(fakeRunImpl); + await bounded('npm', ['root', '-g']); + + expect(seen).toEqual([ + { command: 'npm', args: ['root', '-g'], options: { timeoutMs: 10_000 } }, + ]); + }); +}); + +describe('updateSelf', () => { + /** `npm root -g` answers this root; `pnpm` is never installed on this fake machine. */ + function fakeDetectRun(roots: { npm?: string; pnpm?: string }) { + return async (command: string, _args: readonly string[]): Promise => { + const root = command === 'npm' ? roots.npm : command === 'pnpm' ? roots.pnpm : undefined; + if (root === undefined) return { code: 1, stdout: '', stderr: `${command}: not found` }; + return { code: 0, stdout: root, stderr: '' }; + }; + } + + /** A fake global npm install: packageRoot sits under the root `run` reports. */ + function npmGlobalDeps( + ctx: ReturnType, + overrides: Partial = {}, + ): SelfUpdateDeps { + return { + context: ctx, + execPath: '/usr/local/bin/node', + packageRoot: '/opt/homebrew/lib/node_modules/ailoud', + realpath: async (p: string) => p, + run: fakeDetectRun({ npm: '/opt/homebrew/lib/node_modules' }), + spawn: async () => 0, + // Every test below is either interactive, or does not reach the + // install spawn at all; the two tests that exercise the non-interactive + // --force path override this explicitly. + runCommand: async () => { + throw new Error('runCommand should not be called while deps.interactive is true'); + }, + interactive: true, + ...overrides, + }; + } + + function withTarget(): ReturnType { + return { ...context(), versionSource: source([{ version: NEWER, deprecated: false }]) }; + } + + it('says so and changes nothing when there is nothing newer', async () => { + const ctx = context(); // default versionSource reports the current VERSION + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + }); + + await updateSelf(deps, {}); + + expect(ctx.lines).toEqual([ + `ailoud ${VERSION} is already the newest version you can update to`, + ]); + expect(calls).toEqual([]); + }); + + it('prints a plan and changes nothing with --dry-run', async () => { + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + }); + + await updateSelf(deps, { dryRun: true }); + + expect(calls).toEqual([]); + expect(ctx.lines.some((line) => line.includes(VERSION))).toBe(true); + expect(ctx.lines.some((line) => line.includes(NEWER))).toBe(true); + expect(ctx.lines.some((line) => line.includes(`npm install -g ailoud@${NEWER}`))).toBe(true); + expect(ctx.lines.some((line) => line.toLowerCase().includes('dry run'))).toBe(true); + expect(await ctx.fs.exists(updateLogPath(ctx.paths.userDataDir))).toBe(false); + }); + + it('refuses an npx install and prints the npx command, exiting 0', async () => { + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + packageRoot: '/Users/x/.npm/_npx/abcd1234/node_modules/ailoud', + }); + + await expect(updateSelf(deps, {})).resolves.toBeUndefined(); + + expect(ctx.lines).toContain(`npx ailoud@${NEWER}`); + }); + + it('refuses a project dependency, naming the project', async () => { + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + packageRoot: '/Users/x/code/some-app/node_modules/ailoud', + }); + + await expect(updateSelf(deps, {})).resolves.toBeUndefined(); + + expect(ctx.lines.some((line) => line.includes('/Users/x/code/some-app'))).toBe(true); + }); + + it('names the real target in the unknown-install-method hint', async () => { + // Detection cannot know the target, so its hints carry a `` + // placeholder. By the time one is printed the target IS known, and a + // refusal exists to give the user a command to run -- one they have to + // edit first is a worse version of that. + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { packageRoot: '/opt/somewhere/odd/ailoud' }); + + await expect(updateSelf(deps, {})).resolves.toBeUndefined(); + + const said = ctx.lines.join('\n'); + expect(said).not.toContain(''); + expect(said).toContain(`ailoud@${NEWER}`); + }); + + it('exits non-zero when --force meets an install method it cannot use', async () => { + // A refusal is information; a forced update that cannot happen is an error. + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + packageRoot: '/Users/x/code/some-app/node_modules/ailoud', + }); + + const error: unknown = await updateSelf(deps, { force: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(FailureError); + expect(exitCodeFor(error)).toBe(1); + }); + + it('refuses without a terminal and names --force', async () => { + // The rule the shared confirmation helper already enforces for `rm`. + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { interactive: false }); + + const error: unknown = await updateSelf(deps, {}).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(UsageError); + expect((error as Error).message).toContain('--force'); + }); + + it('declines when the user says no at the prompt, changing nothing', async () => { + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + confirmImpl: async () => false, + }); + + await updateSelf(deps, {}); + + expect(calls).toEqual([]); + expect(ctx.lines).toContain('Nothing was changed.'); + }); + + it('anchors the npm-global install beside the running node, not to a bare "npm"', async () => { + // detectInstallMethod deliberately anchors on execPath because PATH's + // npm can belong to a different Node than the one running us (nvm, fnm, + // asdf, volta). Throwing that anchor away here and spawning bare 'npm' + // would undo the whole reason execPath was threaded through in the first + // place -- see this file's own doc comment on updateSelf. + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + }); + + await updateSelf(deps, { force: true }); + + expect(calls[0]).toEqual(['/usr/local/bin/npm', ['install', '-g', `ailoud@${NEWER}`]]); + }); + + it('anchors the npm-global sweep beside the running node, not to a bare "ailoud"', async () => { + // The rules text is compiled in, so the process being replaced holds the + // old text. This asserts the spawned command is the installed binary's + // `self sync`, resolved the same way the install command is -- a bare + // 'ailoud' resolved off PATH could be a stale, unrelated install (see the + // machine layout in task-8-review.md). + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + }); + + await updateSelf(deps, { force: true }); + + expect(calls).toHaveLength(2); + expect(calls[1]).toEqual(['/usr/local/bin/ailoud', ['self', 'sync']]); + }); + + it('keeps the pnpm-global install as a bare "pnpm", deliberately', async () => { + // pnpm's global bin comes from PNPM_HOME/corepack, not from any one + // Node's install tree, so there is no execPath-equivalent anchor for the + // install -- bare 'pnpm' IS the right resolution. Asserted explicitly so + // nobody "fixes" this into a broken anchor later. + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const pnpmRun = async (command: string, args: readonly string[]): Promise => { + if (command === 'npm') return { code: 1, stdout: '', stderr: 'npm: not found' }; + if (args[0] === 'root') { + return { code: 0, stdout: '/home/x/.local/share/pnpm/global/5/node_modules', stderr: '' }; + } + return { code: 0, stdout: '/home/x/.local/share/pnpm', stderr: '' }; + }; + const deps = npmGlobalDeps(ctx, { + packageRoot: '/home/x/.local/share/pnpm/global/5/node_modules/ailoud', + run: pnpmRun, + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + }); + + await updateSelf(deps, { force: true }); + + expect(calls[0]).toEqual(['pnpm', ['add', '-g', `ailoud@${NEWER}`]]); + }); + + it('invokes the pnpm-global sweep at the path "pnpm bin -g" reports', async () => { + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const pnpmRun = async (command: string, args: readonly string[]): Promise => { + if (command === 'npm') return { code: 1, stdout: '', stderr: 'npm: not found' }; + if (args[0] === 'root') { + return { code: 0, stdout: '/home/x/.local/share/pnpm/global/5/node_modules', stderr: '' }; + } + // args[0] === 'bin': what sweepCommandFor asks to anchor the sweep. + return { code: 0, stdout: '/home/x/.local/share/pnpm', stderr: '' }; + }; + const deps = npmGlobalDeps(ctx, { + packageRoot: '/home/x/.local/share/pnpm/global/5/node_modules/ailoud', + run: pnpmRun, + spawn: async (command, args) => { + calls.push([command, args]); + return 0; + }, + }); + + await updateSelf(deps, { force: true }); + + expect(calls).toHaveLength(2); + expect(calls[1]).toEqual(['/home/x/.local/share/pnpm/ailoud', ['self', 'sync']]); + }); + + it('prints the command to run by hand when the sweep cannot be spawned', async () => { + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + spawn: async (command) => { + if (command === '/usr/local/bin/ailoud') throw new Error('ailoud: command not found'); + return 0; + }, + }); + + await expect(updateSelf(deps, { force: true })).resolves.toBeUndefined(); + + expect(ctx.lines.some((line) => line.includes('ailoud self sync'))).toBe(true); + }); + + it('does not sweep when the install failed', async () => { + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + return 1; + }, + }); + + const error: unknown = await updateSelf(deps, { force: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(FailureError); + expect(calls).toHaveLength(1); + }); + + it('logs the outcome', async () => { + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { spawn: async () => 0 }); + + await updateSelf(deps, { force: true }); + + const log = await ctx.fs.readTextFile(updateLogPath(ctx.paths.userDataDir)); + expect(log).toContain('self update'); + expect(log).toContain(NEWER); + }); + + it('--force with no TTY fails rather than hangs when the manager wants input', async () => { + // This is given its OWN short vitest timeout, deliberately: if a + // regression ever routes this path back through the unbounded + // `spawn` (runInteractive), that fake below never resolves, and this + // test must show up as a FAILING test rather than hang the whole + // suite -- this repository has been bitten by exactly that before. + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + interactive: false, + // Would hang forever if updateSelf ever called this non-interactively. + spawn: () => new Promise(() => undefined), + // What run() does when the bounded timeout actually fires: reject, + // never resolve with a code -- so the real timeout can never present + // as an ordinary non-zero exit. + runCommand: async () => { + throw new FailureError('pnpm timed out after 600000 ms and was killed'); + }, + }); + + const error: unknown = await updateSelf(deps, { force: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(FailureError); + expect((error as Error).message).toContain('timed out'); + }, 2000); + + it('bounds the post-install sweep too when there is no terminal, instead of hanging', async () => { + // Finding C from the review: the sweep spawn called deps.spawn + // (runInteractive) unconditionally, ignoring deps.interactive entirely -- + // unlike the install spawn just above, which is correctly gated. A + // --force run with no TTY whose sweep stalls (a registered project on a + // dead network mount, say) used to hang the parent forever with no + // output. Given its own short vitest timeout for the same reason as the + // install test above: a regression here must show up as a FAILING test, + // not a hung suite. + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + interactive: false, + runCommand: async (command) => { + // The install succeeds... + if (command === '/usr/local/bin/npm') return { code: 0, stdout: '', stderr: '' }; + // ...but the sweep, bounded the same way, times out. + throw new FailureError('ailoud self sync timed out after 600000 ms and was killed'); + }, + // Would hang forever if the sweep were ever routed through the + // unbounded spawn (runInteractive) non-interactively. + spawn: () => new Promise(() => undefined), + }); + + await expect(updateSelf(deps, { force: true })).resolves.toBeUndefined(); + + expect( + ctx.lines.some((line) => line.includes('ailoud self sync') && line.includes('timed out')), + ).toBe(true); + }, 2000); + + it('prints the sweep output and reports a non-zero exit, bounded and with no terminal', async () => { + const ctx = withTarget(); + const deps = npmGlobalDeps(ctx, { + interactive: false, + runCommand: async (command) => { + if (command === '/usr/local/bin/npm') return { code: 0, stdout: '', stderr: '' }; + return { code: 1, stdout: 'refreshed 2 of 3 projects', stderr: 'one project failed' }; + }, + spawn: () => new Promise(() => undefined), + }); + + await expect(updateSelf(deps, { force: true })).resolves.toBeUndefined(); + + expect(ctx.lines).toContain('refreshed 2 of 3 projects'); + expect(ctx.lines).toContain('one project failed'); + expect(ctx.lines.some((line) => line.includes('Could not run "ailoud self sync"'))).toBe(true); + }, 2000); + + it('logs a throwing install spawn, and does not trigger the sweep', async () => { + // If the install spawn THROWS (e.g. the manager binary was not found) + // rather than resolving with a non-zero code, the sweep is correctly + // skipped -- but this used to leave the failure completely unlogged. + const ctx = withTarget(); + const calls: Array<[string, readonly string[]]> = []; + const deps = npmGlobalDeps(ctx, { + spawn: async (command, args) => { + calls.push([command, args]); + throw new Error('npm: command not found'); + }, + }); + + const error: unknown = await updateSelf(deps, { force: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('command not found'); + expect(calls).toHaveLength(1); // the install attempt only, never the sweep + + const log = await ctx.fs.readTextFile(updateLogPath(ctx.paths.userDataDir)); + expect(log).toContain('self update'); + expect(log).toContain('command not found'); + }); +}); + +describe('ailoud self update (CLI wiring)', () => { + it('is already the newest version, printed through the real command', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'update']); + expect(ctx.lines).toEqual([ + `ailoud ${VERSION} is already the newest version you can update to`, + ]); + }); + + it('answers to its one-letter alias inside the group', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'self', 'u']); + expect(ctx.lines).toEqual([ + `ailoud ${VERSION} is already the newest version you can update to`, + ]); + }); + + it('exists as a hidden top-level alias, "ailoud update"', async () => { + const ctx = context(); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'update']); + expect(ctx.lines).toEqual([ + `ailoud ${VERSION} is already the newest version you can update to`, + ]); + }); +}); + +describe('the failure message found in review', () => { + const throwing = (error: Error) => ({ + published: async (): Promise => { + throw error; + }, + }); + + const messageFrom = async (error: Error): Promise => { + const ctx = { + ...context(), + updateRegistryHost: 'registry.npmjs.org', + updateTimeoutMs: 10_000, + versionSource: throwing(error), + }; + const caught: unknown = await buildProgram(ctx) + .parseAsync(['node', 'ailoud', 'self', 'check']) + .catch((thrown: unknown) => thrown); + expect(caught).toBeInstanceOf(FailureError); + return (caught as Error).message; + }; + + it('does not call an HTTP error a timeout', async () => { + // NpmRegistry already reports a status, or an unreadable body, accurately. + // Framing every failure as "timed out" sent the user off to check a + // network that had answered perfectly well. + const message = await messageFrom(new Error('the npm registry answered 503 for ailoud')); + expect(message).toContain('503'); + expect(message).not.toContain('timed out'); + }); + + it('does call an actual timeout a timeout, with the wait', async () => { + const timeout = new Error('The operation was aborted due to timeout'); + timeout.name = 'TimeoutError'; + const message = await messageFrom(timeout); + expect(message).toContain('timed out after 10000ms'); + }); +}); + +describe('the sweep must survive bookkeeping failures (task 7 review)', () => { + class AnnoyedFs extends MemFs { + public constructor(private readonly failOn: string) { + super({}); + } + override async isDirectory(path: string): Promise { + if (path === this.failOn) throw new Error('EACCES: permission denied'); + return super.isDirectory(path); + } + } + + it('keeps a project whose directory cannot be read, instead of forgetting it', async () => { + // EACCES is not evidence the project is gone -- revoked permissions or a + // dead network mount answer the same way. Dropping the entry would forget + // a project that still holds a rules block, silently and for good. + const fs = new AnnoyedFs('/proj/locked'); + const clock = new FakeClock(); + const deps = { fs, clock, userDataDir: '/data/ailoud' }; + await rememberProject(deps, { path: '/proj/locked' }); + + const dropped = await pruneProjects(deps); + + expect(dropped).toEqual([]); + expect((await readProjects(deps)).map((entry) => entry.path)).toEqual(['/proj/locked']); + }); + + it('reports a failure instead of sweeping nothing when the registry cannot be read', async () => { + // Unguarded, this ended the whole sweep with zero rows and zero log + // lines, which is indistinguishable from "there was nothing to do". + class UnreadableFs extends MemFs { + override async readTextFile(path: string): Promise { + if (path.endsWith('projects.json')) throw new Error('EACCES: permission denied'); + return super.readTextFile(path); + } + } + const fs = new UnreadableFs({}); + await fs.writeTextFile('/data/ailoud/projects.json', '[]\n'); + const report = await syncProjects({ + fs, + clock: new FakeClock(), + userDataDir: '/data/ailoud', + home: '/home/x', + }); + + expect(report.failed).toBe(true); + expect(report.rows.some((row) => row.status.startsWith('failed:'))).toBe(true); + }); +}); + +describe('a partial refresh must not be reported as a plain failure', () => { + const BLOCK = 'keep me\n\nold rules\n\n'; + + /** Rejects writes to one agent's rules file, leaving the other's to succeed. */ + class OneUnwritableAgent extends MemFs { + override async writeTextFile(path: string, content: string): Promise { + // `includes`, not `endsWith`: the write now goes to a temporary file + // beside the target, so an `endsWith` match injects nothing. + if (path.includes('GEMINI.md')) throw new Error('EROFS: read-only file system'); + return super.writeTextFile(path, content); + } + } + + it('says some agents were refreshed, and does not record the rules version', async () => { + // One project can hold configs for several agents. Claude's block is + // rewritten and Gemini's write then fails: reporting a plain `failed` + // would claim nothing changed in a file this command had just edited. + // And recording the version after a partial write would make the NEXT + // sweep call the project `current` and skip the block still left stale. + const fs = new OneUnwritableAgent({ + '/proj/a/CLAUDE.md': BLOCK, + '/proj/a/GEMINI.md': BLOCK, + }); + const clock = new FakeClock(); + const registry = { fs, clock, userDataDir: '/data/ailoud' }; + // The directory has to exist, or the sweep prunes the entry before it + // ever reaches an agent. + await fs.ensureDir('/proj/a'); + await rememberProject(registry, { path: '/proj/a' }); + + const report = await syncProjects({ ...registry, home: '/home/x' }); + + const row = report.rows.find((candidate) => candidate.path === '/proj/a'); + expect(row?.status).toMatch(/^failed:/); + expect(row?.status).toContain('some agents were refreshed'); + expect(report.failed).toBe(true); + const entry = (await readProjects(registry)).find((candidate) => candidate.path === '/proj/a'); + expect(entry?.rulesVersion).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/commands/self.ts b/apps/cli/src/commands/self.ts new file mode 100644 index 0000000..b933c79 --- /dev/null +++ b/apps/cli/src/commands/self.ts @@ -0,0 +1,692 @@ +import { fileURLToPath } from 'node:url'; +import { realpath } from 'node:fs/promises'; +import type { Command } from 'commander'; +import { confirm, isCancel } from '@clack/prompts'; +import { FailureError, UsageError, chooseUpdateTarget } from '@ailoud/core'; +import { + detectInstallMethod, + installCommandFor, + run, + runInteractive, + sweepCommandFor, +} from '@ailoud/providers'; +import type { DetectOptions, RunOptions, RunResult } from '@ailoud/providers'; +import type { CliContext } from '../wiring.js'; +import { VERSION } from '../version.js'; +import { AGENTS, defaultHome } from '../mcp/agents.js'; +import { describeTree } from '../completions/generate.js'; +import { refreshCompletions, rootOf } from './selfCompletions.js'; +import { update } from '../mcp/install.js'; +import type { AgentOutcome } from '../mcp/install.js'; +import { pruneProjects, readProjects, registryPath, rememberProject } from '../projects.js'; +import type { ProjectEntry, ProjectsDeps } from '../projects.js'; +import { appendUpdateLog } from '../updateLog.js'; +import { isInteractive } from './setup.js'; + +/** + * The only package this project ever asks the registry about. Never + * `@ailoud/core` or `@ailoud/providers` -- they arrive as this package's own + * dependencies, so their versions follow whatever `ailoud` itself resolves to. + */ +const PACKAGE_NAME = 'ailoud'; + +interface SelfCheckOptions { + readonly json?: boolean; +} + +export interface SelfCheckResult { + readonly current: string; + readonly target: string | null; + readonly updatable: boolean; +} + +/** + * The version `ailoud` is running, and the newest one it could move to. + * + * Throws a `FailureError` when the lookup itself could not be performed -- + * the registry was unreachable, timed out, or answered with something this + * build cannot read -- naming the host and the timeout so the message never + * reads as "you are up to date" when the truth is "this could not be + * checked". Finding no newer version is not a failure: that is `target === + * null` in an ordinary result, because a version check is not a test. + */ +export async function checkForUpdate(context: CliContext): Promise { + let published; + try { + published = await context.versionSource.published(PACKAGE_NAME); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + // Name the timeout only when it WAS one. NpmRegistry already reports an + // HTTP status or an unreadable body accurately, and wrapping those in + // "timed out" sends the user to check a network that answered fine. + const timedOut = + error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'); + const how = timedOut ? ` (timed out after ${context.updateTimeoutMs}ms)` : ''; + throw new FailureError( + `ailoud could not check ${context.updateRegistryHost} for a newer version${how}: ${reason}`, + ); + } + const target = chooseUpdateTarget(VERSION, published); + return { current: VERSION, target, updatable: target !== null }; +} + +export function registerSelfCheck(parent: Command, context: CliContext): void { + parent + .command('check') + .option('--json', 'print one JSON object instead of text') + .description('Check whether a newer version of ailoud is published') + .action(async (options: SelfCheckOptions) => { + await context.ui.frame('Checking for updates', async () => { + const result = await checkForUpdate(context); + if (options.json === true) { + // Raw, undecorated: a machine reader parses this, the same + // contract `ls --json` keeps by writing straight through here + // rather than through the decorated `ui`. + context.ui.content(JSON.stringify(result)); + return; + } + context.ui.content( + result.target === null + ? `ailoud ${result.current} is already the newest published version.` + : `ailoud ${result.current} can update to ${result.target}.`, + ); + }); + }); +} + +/** What `syncProjects` needs beyond the project registry: where the agents' home lives. */ +export interface SyncDeps extends ProjectsDeps { + readonly home: string; +} + +/** + * One project's outcome, for the table `self sync` prints. + * + * The five shapes below are the whole point of the command: a sweep over + * twenty projects must say which two actually changed, not "20 updated". + */ +export interface SyncRow { + readonly path: string; + readonly status: 'refreshed' | 'current' | 'no rules here' | 'gone' | `failed: ${string}`; +} + +export interface SyncReport { + readonly rows: readonly SyncRow[]; + readonly failed: boolean; +} + +/** One line in the update log, naming only the action taken -- never a path's contents. */ +async function logSyncAction(deps: SyncDeps, path: string, status: string): Promise { + await appendUpdateLog(deps, `${deps.clock.nowIso()} self sync ${status} ${path}`); +} + +/** + * Rewrites the rules block in every registered project with this build's + * text, prunes entries whose directory is gone, and reports one row per + * project. + * + * Reuses `update()` from mcp/install.ts unchanged: it already touches only + * the bytes between the AILOUD markers and is already idempotent, which is + * exactly the contract a sweep across other people's repositories needs. + * Only the `local` scope is swept -- a project entry names one directory, + * and the global agent files it: `~/.claude/CLAUDE.md` and friends -- are + * not properties of any one project, so sweeping them once per registered + * project would be both wrong and, over many projects, wasted work. + * + * `FileOutcome.action` (from `update()`, via `install()`) is what tells + * "refreshed" (bytes changed) from "current" (nothing written) -- not a + * version comparison -- so a sweep over twenty projects never claims twenty + * edits when it made two. + * + * One project failing never stops the sweep: each is wrapped in its own + * try/catch, so an unwritable repository lands one `failed:` row instead of + * aborting the other nineteen. `report.failed` is what tells the caller to + * exit non-zero. + */ +export async function syncProjects(deps: SyncDeps): Promise { + const rows: SyncRow[] = []; + let failed = false; + + // Pruning and reading the registry are bookkeeping, and bookkeeping must + // not be able to cancel the work. Unguarded, an EACCES on one registered + // directory -- or on projects.json itself -- ended the whole sweep with no + // rows and no log lines, which looks exactly like "there was nothing to do". + let dropped: readonly ProjectEntry[] = []; + try { + dropped = await pruneProjects(deps); + } catch (error) { + failed = true; + const reason = error instanceof Error ? error.message : String(error); + rows.push({ path: registryPath(deps.userDataDir), status: `failed: ${reason}` }); + await logSyncAction(deps, registryPath(deps.userDataDir), `failed: ${reason}`); + } + for (const entry of dropped) { + rows.push({ path: entry.path, status: 'gone' }); + await logSyncAction(deps, entry.path, 'gone'); + } + + let projects: readonly ProjectEntry[] = []; + try { + projects = await readProjects(deps); + } catch (error) { + failed = true; + const reason = error instanceof Error ? error.message : String(error); + rows.push({ path: registryPath(deps.userDataDir), status: `failed: ${reason}` }); + await logSyncAction(deps, registryPath(deps.userDataDir), `failed: ${reason}`); + } + for (const entry of projects) { + try { + const outcomes: AgentOutcome[] = []; + const failures: string[] = []; + for (const agent of AGENTS) { + if (!agent.scopes.includes('local')) continue; + // Caught PER AGENT, not around the loop. One project can hold configs + // for several agents, and a throw on the second one used to report the + // whole project as `failed` even though the first one's file had + // already been rewritten -- telling the user nothing changed in a + // directory this command had just edited. + try { + const outcome = await update(deps.fs, agent, 'local', deps.home, entry.path); + if (outcome !== null) outcomes.push(outcome); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + + if (failures.length > 0) { + failed = true; + const wrote = outcomes.some((outcome) => + outcome.files.some((file) => file.action !== 'unchanged'), + ); + // Deliberately NOT recording rulesVersion on a partial failure: some + // agent here still has a stale block, and recording the version would + // make the next sweep call this project `current` and skip it. + const status = ( + wrote ? `failed: ${failures[0]} (some agents were refreshed)` : `failed: ${failures[0]}` + ) as SyncRow['status']; + rows.push({ path: entry.path, status }); + await logSyncAction(deps, entry.path, status); + continue; + } + + if (outcomes.length === 0) { + rows.push({ path: entry.path, status: 'no rules here' }); + await logSyncAction(deps, entry.path, 'no rules here'); + continue; + } + + const changed = outcomes.some((outcome) => + outcome.files.some((file) => file.action !== 'unchanged'), + ); + if (changed) { + // Recorded immediately, bypassing rememberProject's 24-hour + // throttle by design (see its own doc comment): a rules write just + // happened, and that is what lets the NEXT sync explain a "current" + // row rather than only report it. + await rememberProject(deps, { + path: entry.path, + ...(entry.libraryDir === undefined ? {} : { libraryDir: entry.libraryDir }), + rulesVersion: VERSION, + }); + rows.push({ path: entry.path, status: 'refreshed' }); + await logSyncAction(deps, entry.path, 'refreshed'); + } else { + rows.push({ path: entry.path, status: 'current' }); + await logSyncAction(deps, entry.path, 'current'); + } + } catch (error) { + failed = true; + const reason = error instanceof Error ? error.message : String(error); + const status = `failed: ${reason}` as const; + rows.push({ path: entry.path, status }); + await logSyncAction(deps, entry.path, status); + } + } + + return { rows, failed }; +} + +/** + * Refreshes installed shell completions, reporting a failure rather than + * raising one. + * + * Completions are a convenience on top of the sync, not the sync itself. A + * `self update` reaches here through the freshly installed binary, by which + * point the new version is already on disk; turning "your completions are + * one version stale" into a non-zero exit would tell the user their upgrade + * did not happen when it did. Same shape as `registerAfterInstall` in + * `commands/mcpInstall.ts`, for the same reason. + */ +async function syncCompletions(context: CliContext, command: Command): Promise { + try { + const outcomes = await refreshCompletions(context, describeTree(rootOf(command))); + for (const outcome of outcomes) { + for (const file of outcome.files) { + if (file.action === 'created' || file.action === 'updated') { + context.ui.success(`${file.action.padEnd(9)} ${file.path}`); + } + } + // The same advisory `self completions install/update` surfaces via + // `report()` in selfCompletions.ts -- see ShellTarget.warnAbout. Without + // this, a user whose completions were refreshed automatically after + // `self update` never learns their macOS login shell does not read the + // file that was just written; they only find out when Tab does nothing. + if (outcome.note !== '') context.ui.warn(outcome.note); + } + } catch (error) { + context.ui.warn( + `could not refresh shell completions: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function registerSelfSync(parent: Command, context: CliContext): void { + parent + .command('sync') + .description('Refresh the rules block in every project, and any installed completions') + .action(async (_options: unknown, command: Command) => { + await context.ui.frame('Syncing rules', async () => { + const report = await syncProjects({ + fs: context.fs, + clock: context.clock, + userDataDir: context.paths.userDataDir, + home: defaultHome(), + }); + + // Before the reporting below, because that has both an early return + // and a throw in it: a project registry that is empty, or one project + // that failed, must not decide whether completions get refreshed. + // `self update` reaches this command through the NEWLY installed + // binary, which is the only process whose command tree is the one the + // completions should describe. + await syncCompletions(context, command); + + if (report.rows.length === 0) { + context.ui.content('No projects registered yet.'); + return; + } + for (const row of report.rows) { + const line = `${row.status}: ${row.path}`; + if (row.status === 'refreshed') { + context.ui.success(line); + } else if (row.status.startsWith('failed:')) { + context.ui.warn(line); + } else { + context.ui.note(line); + } + } + if (report.failed) { + throw new FailureError( + 'ailoud self sync: at least one project failed to refresh; see the rows above.', + ); + } + }); + }); +} + +/** + * What `updateSelf` needs beyond `checkForUpdate`'s `CliContext`: everything + * that touches the machine or spawns another process, grouped here rather + * than folded into `CliContext` itself so a test can replace every one of + * them. `registerSelfUpdate` below is the only place the real node + * primitives are ever passed in; every test in this file passes fakes + * instead, so no test ever spawns a real process. + */ +export interface SelfUpdateDeps { + readonly context: CliContext; + /** + * `process.execPath`: which Node is running us, so a global install under + * nvm, fnm, asdf or volta is recognised even though the npm on PATH often + * belongs to a different Node version. Required by `DetectOptions` -- see + * its doc comment in `installMethod.ts`. + */ + readonly execPath: string; + /** Where this package sits on disk, resolved from `import.meta.url`. */ + readonly packageRoot: string; + readonly realpath: (path: string) => Promise; + /** + * What `detectInstallMethod` calls to ask `npm`/`pnpm` for their global + * root. Bounded to 10 seconds in production by `boundedDetectRun` -- see + * its own doc comment -- so a hung `npm root -g` cannot hang the whole + * command. + */ + readonly run: DetectOptions['run']; + /** + * Runs a subprocess with the parent's own stdio, so its output streams to + * the real terminal as it happens, and resolves to its exit code. Bound to + * `runInteractive` (from `@ailoud/providers`) in production. This is the + * one seam that would otherwise run a real package manager, so every test + * in this file injects a fake here instead of that binding. + * + * Used for the package-manager install, and for the post-install `self + * sync` sweep, but ONLY when `interactive` is true for either -- `run + * Interactive` has no timeout by design, which is only safe when a real + * terminal is watching and can interrupt it. Neither spawn is safe to + * leave ungated: `self sync` does not prompt for input either, but "does + * not prompt" is not the same as "cannot stall" -- a registered project on + * a dead network mount is enough to hang this unconditionally, which is + * exactly the defect the non-interactive branch below exists to close. + */ + readonly spawn: (command: string, args: readonly string[]) => Promise; + /** + * Runs the package-manager install, and the post-install sweep, BOUNDED -- + * for the one case `spawn` (`runInteractive`) must never be used for + * either: `--force` with no terminal attached. Bound to `run` (from + * `@ailoud/providers`) in production, given a generous but finite timeout + * by `updateSelf` itself -- see its own doc comment for why an unbounded + * wait is not safe there. + */ + readonly runCommand: ( + command: string, + args: readonly string[], + options?: RunOptions, + ) => Promise; + /** + * Whether there is a real terminal to confirm ON, which means STDIN is a + * TTY and this is not CI -- see `isInteractive` in `setup.ts`, which is + * what every caller uses. + * + * Stdin only, deliberately: the question is whether anyone can ANSWER a + * prompt, and that is stdin's business. `ailoud self update > out.txt` from + * a terminal is still interactive, and should be. This comment used to + * claim both ends had to be a TTY, which no call site checks. + */ + readonly interactive: boolean; + /** Overrides `@clack/prompts`' `confirm` in tests. */ + readonly confirmImpl?: (message: string) => Promise; +} + +export interface SelfUpdateOptions { + readonly force?: boolean; + readonly dryRun?: boolean; +} + +/** + * Wraps a `run` implementation so every call `detectInstallMethod` makes + * through it carries a hard 10 second cap. + * + * Detection happens automatically, before anything is printed or confirmed -- + * unlike the package-manager spawn later in `updateSelf`, which only runs + * after the user has already seen and agreed to the plan. `run()`'s own + * default timeout is thirty minutes (see `providers/process/run.ts`); left at + * that default, a hung `npm root -g` would hang `self update` for half an + * hour with nothing on screen to explain why. + */ +export function boundedDetectRun( + runImpl: (command: string, args: readonly string[], options?: RunOptions) => Promise, +): DetectOptions['run'] { + return (command, args) => runImpl(command, args, { timeoutMs: 10_000 }); +} + +/** + * How long the FORCED, non-interactive install is allowed to run before it + * is treated as hung rather than merely slow. Ten minutes is ample for a + * real package install; it exists only to turn "the manager is waiting on a + * prompt nobody can answer" into a failure instead of an infinite wait. See + * `updateSelf`'s own doc comment for the full reasoning. + */ +const FORCE_INSTALL_TIMEOUT_MS = 10 * 60_000; + +/** + * How long the FORCED, non-interactive `self sync` sweep is allowed to run + * before it is treated as hung rather than merely slow. Same duration and + * the same reasoning as `FORCE_INSTALL_TIMEOUT_MS`: this exists only to turn + * a stall -- a registered project sitting on a dead network mount, say -- + * into a failure instead of an infinite, silent wait. See `updateSelf`'s own + * doc comment for the full reasoning. + */ +const FORCE_SWEEP_TIMEOUT_MS = 10 * 60_000; + +/** One line in the update log, naming only the action taken. */ +async function logUpdateAction(context: CliContext, status: string): Promise { + await appendUpdateLog( + { fs: context.fs, userDataDir: context.paths.userDataDir }, + `${context.clock.nowIso()} self update ${status}`, + ); +} + +/** The real `confirm`, the way `commands/setup.ts` uses it: `isCancel` is "no". */ +const defaultConfirm = async (message: string): Promise => { + const answer = await confirm({ message }); + if (isCancel(answer)) return false; + return answer === true; +}; + +/** + * Installs a newer ailoud, then re-syncs the rules block through a fresh + * subprocess -- never in this one. + * + * That last part is load-bearing, not a style choice: the rules text is + * compiled into the code, so the process being replaced still holds the OLD + * text. If IT swept the registered projects, it would write the old rules + * into every one of them -- precisely the staleness `self sync` exists to + * fix. So the sweep only ever happens by spawning `ailoud self sync` as a + * subprocess. + * + * Both the install and the sweep are anchored, never a bare command name + * resolved off PATH: under nvm/fnm/asdf/volta, PATH's `npm`/`ailoud` can + * belong to an entirely different Node install than the one running us, + * which would install the new version into the wrong tree and then sweep + * every registered project with a DIFFERENT, possibly older, binary's + * compiled-in rules -- both while reporting success. `installCommandFor` and + * `sweepCommandFor` (`@ailoud/providers`) are the single places that decide + * those two argvs, anchored to `deps.execPath` for `npm-global` and to + * `pnpm bin -g` for `pnpm-global` -- see their own doc comments. + * + * Both the install AND the sweep only ever wait unboundedly (`deps.spawn`, + * bound to `runInteractive`) when `deps.interactive` is true, i.e. a real + * terminal is attached and can answer a prompt or interrupt it. `--force` + * with no terminal is the one path that can still reach either spawn with + * nobody able to answer a prompt or interrupt a stall -- some package + * managers do prompt on first global use (`pnpm add -g` before its bin/PATH + * setup has run once), and the sweep can stall for reasons of its own (a + * registered project on a dead network mount, say) even though it never + * prompts -- so that path uses `deps.runCommand` (bound to the bounded + * `run`) instead, for both, with a generous but finite timeout, so either + * one stuck waiting FAILS after that timeout, with its output shown, rather + * than hanging forever. This follows the same convention + * `provision/llamaInstall.ts` uses for `runInteractive`: gate on + * interactivity before ever calling it. + * + * Follows the eight steps of the design's `self update` section in order: + * resolve the target, detect the install method (three of its five kinds are + * refusals), print the plan, confirm (skipped by `--force`; `--dry-run` stops + * here, having changed nothing), spawn the package manager, spawn the new + * binary's `self sync` only on success, and log the outcome -- including a + * throwing install spawn, which used to vanish unlogged. + */ +export async function updateSelf(deps: SelfUpdateDeps, options: SelfUpdateOptions): Promise { + const { context } = deps; + + const result = await checkForUpdate(context); + if (result.target === null) { + context.ui.content(`ailoud ${result.current} is already the newest version you can update to`); + return; + } + const target = result.target; + + const method = await detectInstallMethod({ + execPath: deps.execPath, + packageRoot: deps.packageRoot, + realpath: deps.realpath, + run: deps.run, + }); + + // npx, project and unknown all carry a hint and nothing else: a refusal + // that does not say what to do instead is just a failure. Narrowed by + // `method.kind` rather than by `installCommandFor(...) === null`, so + // TypeScript knows `method.hint` exists on every branch that reads it. + if (method.kind === 'npx' || method.kind === 'project' || method.kind === 'unknown') { + // The hints carry a `` placeholder because detection does not + // know the target -- but by here we do, so fill it in. A refusal exists to + // hand the user a command they can run, and one they have to edit first + // is a worse version of that. + const hint = method.hint.replaceAll('', target); + context.ui.content(hint); + if (options.force === true) { + // A refusal is information; a forced update that cannot happen is an + // error, because --force asked for a guarantee this install method + // cannot give. + throw new FailureError(`ailoud self update cannot install this way: ${hint}`); + } + return; + } + + const command = installCommandFor(method, target, deps.execPath); + if (command === null) { + // Unreachable today: installCommandFor only returns null for the three + // kinds handled above. Kept as a real check rather than a cast, so a + // future InstallMethod variant fails loudly here instead of spawning + // `undefined`. + throw new FailureError('ailoud self update: no install command for this install method'); + } + const managerCommand = command[0]; + if (managerCommand === undefined) { + throw new FailureError('ailoud self update: the install command was empty'); + } + const managerArgs = command.slice(1); + + const projects = await readProjects({ + fs: context.fs, + clock: context.clock, + userDataDir: context.paths.userDataDir, + }); + + context.ui.content(`Current version: ${result.current}`); + context.ui.content(`Target version: ${target}`); + context.ui.content(`Install command: ${command.join(' ')}`); + context.ui.content( + projects.length === 0 + ? 'No registered projects to refresh.' + : `${projects.length} registered project${projects.length === 1 ? '' : 's'} will have their rules refreshed.`, + ); + + if (options.dryRun === true) { + context.ui.content('Dry run: nothing was changed.'); + return; + } + + if (options.force !== true) { + if (!deps.interactive) { + throw new UsageError( + `ailoud self update needs confirmation before installing ailoud ${target}, but there is ` + + 'no terminal to ask on. Re-run with --force to confirm in advance.', + ); + } + const confirmImpl = deps.confirmImpl ?? defaultConfirm; + const consented = await confirmImpl(`Install ailoud ${target}?`); + if (!consented) { + context.ui.content('Nothing was changed.'); + return; + } + } + + let code: number; + try { + if (deps.interactive) { + code = await deps.spawn(managerCommand, managerArgs); + } else { + // Only --force reaches here without a terminal (the gate above throws + // otherwise). runInteractive has no timeout and would hang forever if + // the manager wants to prompt, so this bounds the wait instead -- see + // FORCE_INSTALL_TIMEOUT_MS and this function's own doc comment. + const bounded = await deps.runCommand(managerCommand, managerArgs, { + timeoutMs: FORCE_INSTALL_TIMEOUT_MS, + }); + // run() buffers output rather than streaming it live the way + // runInteractive does, so it has to be printed after the fact -- this + // is the only way whoever (or whatever) is watching a --force, + // no-terminal run sees what the manager actually did. + if (bounded.stdout.length > 0) context.ui.content(bounded.stdout); + if (bounded.stderr.length > 0) context.ui.content(bounded.stderr); + code = bounded.code; + } + } catch (error) { + // The install spawn THROWING (ENOENT, or the bounded run's own timeout) + // is different from it exiting non-zero: the sweep is correctly skipped + // either way, but a throw used to reach no log line at all. + const reason = error instanceof Error ? error.message : String(error); + await logUpdateAction(context, `install threw for "${command.join(' ')}": ${reason}`); + throw error; + } + if (code !== 0) { + await logUpdateAction(context, `install failed, "${command.join(' ')}" exited ${code}`); + throw new FailureError(`ailoud self update: "${command.join(' ')}" exited with code ${code}`); + } + await logUpdateAction(context, `installed ${target}`); + context.ui.content(`ailoud updated to ${target}.`); + + // The NEW binary, as a fresh subprocess -- never this one. See this + // function's own doc comment for why that is not optional, and for why the + // command below is anchored rather than a bare 'ailoud'. + const sweep = await sweepCommandFor(method, deps.execPath, deps.run); + const sweepCommand = sweep?.[0]; + if (sweep === null || sweepCommand === undefined) { + context.ui.content('Could not determine the command to refresh rules automatically.'); + context.ui.content('Run it by hand: ailoud self sync'); + return; + } + const sweepArgs = sweep.slice(1); + try { + if (deps.interactive) { + await deps.spawn(sweepCommand, sweepArgs); + } else { + // Same reasoning as the install spawn above: deps.spawn is bound to + // runInteractive, which has no timeout and is only safe to call when a + // real terminal is attached to interrupt it. `self sync` never prompts + // for input, but "does not prompt" is not the same as "cannot stall" -- + // a registered project on a dead network mount is enough to hang this + // unconditionally, and this is the exact path (--force, no TTY) that + // used to reach it ungated. The bounded runCommand fails instead, after + // a generous wait, with its output printed either way. + const bounded = await deps.runCommand(sweepCommand, sweepArgs, { + timeoutMs: FORCE_SWEEP_TIMEOUT_MS, + }); + if (bounded.stdout.length > 0) context.ui.content(bounded.stdout); + if (bounded.stderr.length > 0) context.ui.content(bounded.stderr); + if (bounded.code !== 0) { + throw new FailureError(`"${sweep.join(' ')}" exited with code ${bounded.code}`); + } + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + context.ui.content(`Could not run "ailoud self sync" automatically (${reason}).`); + context.ui.content('Run it by hand: ailoud self sync'); + } +} + +interface SelfUpdateCliOptions { + readonly force?: boolean; + readonly dryRun?: boolean; +} + +export function registerSelfUpdate(parent: Command, context: CliContext): void { + parent + .command('update') + .option( + '--force', + 'skip confirmation; fail rather than refuse quietly when this install cannot be updated', + ) + .option('--dry-run', 'print the plan without installing or syncing anything') + .description('Install a newer ailoud, then refresh the rules block in every registered project') + .action(async (options: SelfUpdateCliOptions) => { + await context.ui.frame('Updating ailoud', async () => { + const deps: SelfUpdateDeps = { + context, + execPath: process.execPath, + // self.ts sits at /dist/commands/self.js once built; + // '../..' from there is the package root itself. + packageRoot: fileURLToPath(new URL('../..', import.meta.url)), + realpath, + run: boundedDetectRun(run), + spawn: runInteractive, + runCommand: run, + interactive: isInteractive(process.env, process.stdin.isTTY === true), + }; + await updateSelf(deps, { + force: options.force === true, + dryRun: options.dryRun === true, + }); + }); + }); +} diff --git a/apps/cli/src/commands/selfCompletions.test.ts b/apps/cli/src/commands/selfCompletions.test.ts new file mode 100644 index 0000000..0715206 --- /dev/null +++ b/apps/cli/src/commands/selfCompletions.test.ts @@ -0,0 +1,231 @@ +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; +import { MemFs } from '@ailoud/core/testing'; +import { UsageError } from '@ailoud/core'; +import type { CliContext } from '../wiring.js'; +import type { CommandNode } from '../completions/generate.js'; +import { install } from '../completions/install.js'; +import { findShell } from '../completions/shells.js'; +import { + parseShells, + refreshCompletions, + registerSelfCompletions, + rootOf, +} from './selfCompletions.js'; + +const HOME = '/home/ann'; +const CONFIG = '/home/ann/.config'; +const DATA = '/home/ann/.local/share/ailoud'; +const PLACES = { home: HOME, configHome: CONFIG, userDataDir: DATA }; + +// Mocked module-wide so the "--yes" test below can assert the prompt was +// never reached, rather than hoping a real terminal-less multiselect() call +// fails loudly instead of hanging the test runner. +const clack = vi.hoisted(() => ({ + multiselect: vi.fn(async () => { + throw new Error('multiselect must not be called when --yes is set'); + }), + isCancel: vi.fn(() => false), +})); +vi.mock('@clack/prompts', () => clack); + +/** What each ui channel was called with, and nothing else -- see mcpInstall.test.ts's uiSpy. */ +interface UiCalls { + readonly content: string[]; + readonly success: string[]; + readonly warn: string[]; + readonly note: string[]; +} + +/** A context whose `ui` records which channel each line went to, over the real fs and paths. */ +function contextWithUi(fs: MemFs): { context: CliContext; calls: UiCalls } { + const calls: UiCalls = { content: [], success: [], warn: [], note: [] }; + const context = { + fs, + paths: { configHome: CONFIG, userDataDir: DATA }, + ui: { + content: (text: string): void => { + calls.content.push(text); + }, + success: (text: string): void => { + calls.success.push(text); + }, + warn: (text: string): void => { + calls.warn.push(text); + }, + note: (text: string): void => { + calls.note.push(text); + }, + frame: async (_label: string, task: () => Promise): Promise => task(), + }, + } as unknown as CliContext; + return { context, calls }; +} + +const TREE: CommandNode = { + name: 'ailoud', + description: 'root', + aliases: [], + options: [], + children: [{ name: 'ls', description: 'list', aliases: [], options: [], children: [] }], +}; + +/** Only `fs` and `paths` are reached by the functions under test. */ +function contextWith(fs: MemFs): CliContext { + return { fs, paths: { configHome: CONFIG, userDataDir: DATA } } as unknown as CliContext; +} + +describe('rootOf', () => { + it('walks up to the root from a command nested two deep', () => { + // The completion script must describe the whole tree, and an action only + // ever holds its own command. Building a second program instead would + // open the database for a script that does not need a library. + const program = new Command().name('ailoud'); + const group = program.command('self'); + const leaf = group.command('completions'); + expect(rootOf(leaf).name()).toBe('ailoud'); + expect(rootOf(program).name()).toBe('ailoud'); + }); +}); + +describe('parseShells', () => { + const context = contextWith(new MemFs({})); + + it('takes a comma-separated list', async () => { + const targets = await parseShells(context, 'bash,fish', PLACES, {}); + expect(targets.map((t) => t.shell)).toEqual(['bash', 'fish']); + }); + + it('names the valid shells when given one that does not exist', async () => { + // ash and dash land here: they have no programmable completion at all, so + // there is nothing to install and saying so beats writing a dead file. + await expect(parseShells(context, 'ash', PLACES, {})).rejects.toBeInstanceOf(UsageError); + await expect(parseShells(context, 'ash', PLACES, {})).rejects.toThrow(/bash, zsh, fish/); + }); + + it('resolves "auto" to the detected shells only', async () => { + const fs = new MemFs({ [`${HOME}/.zshrc`]: '' }); + const targets = await parseShells(contextWith(fs), 'auto', PLACES, {}); + expect(targets.map((t) => t.shell)).toEqual(['zsh']); + }); + + it('resolves "all" without looking at the machine', async () => { + const targets = await parseShells(context, 'all', PLACES, {}); + expect(targets.map((t) => t.shell)).toEqual(['bash', 'zsh', 'fish']); + }); +}); + +describe('refreshCompletions', () => { + it('returns nothing when no shell has completions installed', async () => { + const fs = new MemFs({ [`${HOME}/.bashrc`]: 'export PATH=x\n' }); + expect(await refreshCompletions(contextWith(fs), TREE, { HOME: HOME })).toEqual([]); + expect(await fs.exists(`${DATA}/completions/ailoud.bash`)).toBe(false); + }); + + it('sweeps a shell the user does not run, and skips one with nothing installed', async () => { + // The sweep must not be limited to the detected shells or to $SHELL: an + // earlier install may have written into a shell since abandoned, where a + // stale script keeps completing commands that no longer exist. + const fs = new MemFs({}); + await install(fs, findShell('zsh')!, TREE, PLACES); + await fs.writeTextFile(`${DATA}/completions/_ailoud`, '# stale\n'); + + const outcomes = await refreshCompletions(contextWith(fs), TREE, { HOME: HOME }); + + expect(outcomes.map((o) => o.shell)).toEqual(['zsh']); + expect(await fs.readTextFile(`${DATA}/completions/_ailoud`)).not.toContain('stale'); + expect(await fs.exists(`${DATA}/completions/ailoud.bash`)).toBe(false); + expect(await fs.exists(`${CONFIG}/fish/completions/ailoud.fish`)).toBe(false); + }); +}); + +describe('registerSelfCompletions: print', () => { + it('writes the script through content(), and touches no file', async () => { + const fs = new MemFs({}); + const { context, calls } = contextWithUi(fs); + const program = new Command().name('ailoud'); + registerSelfCompletions(program, context); + + await program.parseAsync(['node', 'ailoud', 'completions', 'print', 'zsh']); + + // content() is the channel that stays byte-exact when stdout is + // redirected -- the whole point of `ailoud self completions print zsh > + // _ailoud`. A regression that routed this through note() or success() + // instead would look identical on a terminal (PlainUi renders both with + // no prefix) and only break the moment the output is piped, which a + // terminal-only test run could never catch. + expect(calls.content).toHaveLength(1); + expect(calls.content[0]).toContain('#compdef ailoud'); + expect(calls.success).toEqual([]); + expect(calls.warn).toEqual([]); + expect(calls.note).toEqual([]); + + // No install side effect: print only ever renders, it never writes. + expect(await fs.exists(`${DATA}/completions/_ailoud`)).toBe(false); + expect(await fs.exists(`${HOME}/.zshrc`)).toBe(false); + }); +}); + +describe('registerSelfCompletions: install --yes', () => { + it('resolves to the detected shells without prompting', async () => { + // Forces chooseShells' interactive branch open (a real terminal, no CI), + // so the only thing that can be stopping the prompt is --yes itself -- + // without this, the non-interactive fallback would take the same path + // for the wrong reason and the test would pass even if --yes did nothing. + const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const originalCi = process.env['CI']; + const originalHome = process.env['HOME']; + const originalShell = process.env['SHELL']; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + delete process.env['CI']; + process.env['HOME'] = HOME; + // Pinned for the same reason HOME is: `detect` treats `$SHELL` as one of + // its three signals (see ../completions/shells.ts), and it reads the real + // environment even though the filesystem here is a MemFs holding only + // `.zshrc`. Left alone, this test asserted the machine's login shell + // rather than its own fixture -- passing wherever `$SHELL` is zsh and + // failing wherever it is bash, which is every Linux CI runner. + process.env['SHELL'] = '/bin/zsh'; + try { + const fs = new MemFs({ [`${HOME}/.zshrc`]: '' }); + const { context } = contextWithUi(fs); + const program = new Command().name('ailoud'); + registerSelfCompletions(program, context); + + await program.parseAsync(['node', 'ailoud', 'completions', 'install', '--yes']); + + expect(clack.multiselect).not.toHaveBeenCalled(); + expect(await fs.exists(`${DATA}/completions/_ailoud`)).toBe(true); + expect(await fs.exists(`${DATA}/completions/ailoud.bash`)).toBe(false); + expect(await fs.exists(`${CONFIG}/fish/completions/ailoud.fish`)).toBe(false); + } finally { + if (isTtyDescriptor === undefined) delete (process.stdin as { isTTY?: boolean }).isTTY; + else Object.defineProperty(process.stdin, 'isTTY', isTtyDescriptor); + if (originalCi === undefined) delete process.env['CI']; + else process.env['CI'] = originalCi; + if (originalHome === undefined) delete process.env['HOME']; + else process.env['HOME'] = originalHome; + if (originalShell === undefined) delete process.env['SHELL']; + else process.env['SHELL'] = originalShell; + } + }); +}); + +describe('registerSelfCompletions: install --shell fish', () => { + it('reports only one file for a shell with no rc requirement', async () => { + const fs = new MemFs({}); + const { context, calls } = contextWithUi(fs); + const program = new Command().name('ailoud'); + registerSelfCompletions(program, context); + + await program.parseAsync(['node', 'ailoud', 'completions', 'install', '--shell', 'fish']); + + // fish has no rcPath (see SHELL_TARGETS in completions/shells.ts): install() + // pushes only the script's own FileOutcome for it, never a second one for + // a startup file fish does not have. A regression that assumed every + // shell needs an rc file would report two lines here instead of one. + expect(calls.success).toHaveLength(1); + expect(calls.success[0]).toContain(`${CONFIG}/fish/completions/ailoud.fish`); + expect(await fs.exists(`${CONFIG}/fish/completions/ailoud.fish`)).toBe(true); + }); +}); diff --git a/apps/cli/src/commands/selfCompletions.ts b/apps/cli/src/commands/selfCompletions.ts new file mode 100644 index 0000000..532532b --- /dev/null +++ b/apps/cli/src/commands/selfCompletions.ts @@ -0,0 +1,262 @@ +import { isCancel, multiselect } from '@clack/prompts'; +import type { Command } from 'commander'; +import { UsageError } from '@ailoud/core'; +import type { CliContext } from '../wiring.js'; +import { describeTree, renderCompletions } from '../completions/generate.js'; +import type { CommandNode, Shell } from '../completions/generate.js'; +import { SHELL_TARGETS, detect, findShell, shellIds } from '../completions/shells.js'; +import type { ShellTarget } from '../completions/shells.js'; +import { install, refresh, uninstall } from '../completions/install.js'; +import type { FileOutcome, Places, ShellOutcome } from '../completions/install.js'; +import { isInteractive } from './setup.js'; + +interface Options { + readonly shell?: string; + readonly yes?: boolean; +} + +/** + * The directories the writers need, taken from the resolved paths rather than + * from the environment. + * + * `userDataDir` and not `dataDir`: a completion script is a property of the + * user's shell, not of one repository. Inside a project library `dataDir` + * points at that project's `.ailoud/`, and a script written there would be + * installed once per repository and lost on the next `cd`. + */ +export function placesFor( + context: CliContext, + env: Record = process.env, +): Places { + return { + home: env['HOME'] ?? '', + configHome: context.paths.configHome, + userDataDir: context.paths.userDataDir, + }; +} + +/** + * The root command of the running invocation. + * + * Walked up from the action's own command rather than built afresh: + * `buildProgram` needs a `CliContext`, which opens the database, and a + * completion script does not need a library to be readable. Using the live + * tree is also what guarantees the script describes the commands this build + * actually has instead of a list kept beside them. + */ +export function rootOf(command: Command): Command { + let at = command; + while (at.parent !== null && at.parent !== undefined) at = at.parent; + return at; +} + +/** Resolves `--shell`: a comma-separated list, or "auto" for the detected ones. */ +export async function parseShells( + context: CliContext, + raw: string, + places: Places, + env: Record, +): Promise { + const wanted = raw.trim().toLowerCase(); + if (wanted === 'all') return [...SHELL_TARGETS]; + if (wanted === 'auto') { + const found: ShellTarget[] = []; + for (const target of SHELL_TARGETS) { + if (await detect(context.fs, target, places.home, places.configHome, env)) found.push(target); + } + return found; + } + return wanted.split(',').map((id) => { + const target = findShell(id); + if (target === undefined) { + throw new UsageError(`unknown shell "${id}"; choose from: ${shellIds()}`); + } + return target; + }); +} + +/** + * Asks which shells, with the detected ones pre-selected. + * + * Pre-selecting what was found is the whole ergonomics of this prompt: the + * common answer is "the ones I actually use", and it takes no keystrokes. + */ +async function askShells( + context: CliContext, + places: Places, + env: Record, +): Promise { + const rows = []; + for (const target of SHELL_TARGETS) { + const found = await detect(context.fs, target, places.home, places.configHome, env); + rows.push({ + value: target.shell, + label: `${target.label} (${found ? 'detected' : 'not found'})`, + found, + }); + } + const answer = await multiselect({ + message: 'Which shells should get completions?', + options: rows.map(({ value, label }) => ({ value, label })), + initialValues: rows.filter((row) => row.found).map((row) => row.value), + required: false, + }); + if (isCancel(answer)) throw new UsageError('self completions cancelled'); + return (answer as string[]).map((id) => findShell(id)!); +} + +/** + * The shells to act on. + * + * `--yes` here means `--shell auto`, which is deliberately unlike `--yes` + * elsewhere in this CLI. Running `self completions install` IS the request to + * install, so there is no unasked consent question for `--yes` to answer + * wrongly -- it only says "do not make me pick from a list". + */ +async function chooseShells( + context: CliContext, + options: Options, + places: Places, + env: Record, +): Promise { + if (options.shell !== undefined) return parseShells(context, options.shell, places, env); + const interactive = isInteractive(env, process.stdin.isTTY === true) && options.yes !== true; + if (!interactive) return parseShells(context, 'auto', places, env); + return askShells(context, places, env); +} + +/** + * One line per file touched, so the user can see exactly what changed. + * + * Exported for setup.ts (`offerCompletions`), which renders the same + * `install()` outcomes at the end of a provisioning run: a wording or format + * change to what `self completions install` prints must reach that path too, + * not silently diverge from it. + */ +export function reportFile(context: CliContext, file: FileOutcome): void { + const line = `${file.action.padEnd(9)} ${file.path}`; + if (file.action === 'created' || file.action === 'updated') { + context.ui.success(line); + } else { + context.ui.note(line); + } +} + +/** See reportFile's doc comment: exported for the same reason. */ +export function report(context: CliContext, outcomes: readonly ShellOutcome[]): void { + for (const outcome of outcomes) { + for (const file of outcome.files) reportFile(context, file); + // The advisory, when a shell has one, belongs beside the files it is + // about -- see ShellTarget.warnAbout for the case it exists for. + if (outcome.note !== '') context.ui.warn(outcome.note); + } +} + +/** + * Refreshes the completions of every shell that already has them, and + * installs for none that do not. + * + * Every entry in `SHELL_TARGETS`, not only the detected ones and not only the + * one `$SHELL` names: an earlier install may have written into a shell the + * user has since stopped using, and the stale script left there keeps + * completing commands that no longer exist. `refresh` itself takes no + * interest in the environment, so the sweep has to happen here. + */ +export async function refreshCompletions( + context: CliContext, + tree: CommandNode, + env: Record = process.env, +): Promise { + const places = placesFor(context, env); + const outcomes: ShellOutcome[] = []; + for (const target of SHELL_TARGETS) { + const outcome = await refresh(context.fs, target, tree, places); + if (outcome !== null) outcomes.push(outcome); + } + return outcomes; +} + +export function registerSelfCompletions(parent: Command, context: CliContext): void { + const completions = parent + .command('completions') + .description('Generate and install shell completions for ailoud'); + + completions + .command('install') + .description('Write the completion script and wire it into your shell') + .option('-s, --shell ', `comma-separated shells, or "auto"/"all": ${shellIds()}`) + .option('-y, --yes', 'no prompt: use the detected shells') + .action(async (options: Options, command: Command) => { + await context.ui.frame('Installing completions', async () => { + const places = placesFor(context); + const targets = await chooseShells(context, options, places, process.env); + if (targets.length === 0) { + context.ui.warn('No shells selected, so nothing was written.'); + context.ui.warn(`Run again with --shell to name one: ${shellIds()}`); + return; + } + const tree = describeTree(rootOf(command)); + const outcomes: ShellOutcome[] = []; + for (const target of targets) { + outcomes.push(await install(context.fs, target, tree, places)); + } + report(context, outcomes); + context.ui.note('Open a new shell, or source your startup file, to pick them up.'); + }); + }); + + completions + .command('uninstall') + .description('Remove the completion script and the block it added') + .option('-s, --shell ', `comma-separated shells, or "auto"/"all" (default): ${shellIds()}`) + .action(async (options: Options) => { + await context.ui.frame('Removing completions', async () => { + const places = placesFor(context); + const targets = await parseShells(context, options.shell ?? 'all', places, process.env); + const outcomes: ShellOutcome[] = []; + for (const target of targets) { + outcomes.push(await uninstall(context.fs, target, places)); + } + const touched = outcomes.flatMap((outcome) => + outcome.files.filter((file) => file.action === 'removed' || file.action === 'cleaned'), + ); + if (touched.length === 0) { + // Said plainly rather than reported as a success: an uninstall that + // claims to have cleaned files it never touched teaches distrust. + context.ui.warn('Nothing to remove: no shell here had ailoud completions.'); + return; + } + report(context, outcomes); + }); + }); + + completions + .command('update') + .description('Refresh completions wherever they are already installed') + .action(async (_options: unknown, command: Command) => { + await context.ui.frame('Updating completions', async () => { + const outcomes = await refreshCompletions(context, describeTree(rootOf(command))); + if (outcomes.length === 0) { + context.ui.warn('Nothing to update: no shell here has ailoud completions.'); + context.ui.warn('Run "ailoud self completions install" first.'); + return; + } + report(context, outcomes); + }); + }); + + completions + .command('print') + .argument('', `which shell to render for: ${shellIds()}`) + .description('Write the completion script to stdout without installing it') + .action(async (shell: string, _options: unknown, command: Command) => { + const target = findShell(shell); + if (target === undefined) { + throw new UsageError(`unknown shell "${shell}"; choose from: ${shellIds()}`); + } + // Through content(), never straight to stdout: content() is the channel + // that stays byte-exact when stdout is redirected, which is the whole + // point of `ailoud self completions print zsh > _ailoud`. + context.ui.content(renderCompletions(target.shell as Shell, describeTree(rootOf(command)))); + }); +} diff --git a/apps/cli/src/commands/setup.test.ts b/apps/cli/src/commands/setup.test.ts index e232100..ebc42da 100644 --- a/apps/cli/src/commands/setup.test.ts +++ b/apps/cli/src/commands/setup.test.ts @@ -2,11 +2,15 @@ import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; import type { Action } from '@ailoud/core'; +import { MemFs } from '@ailoud/core/testing'; import { + DEFAULT_MODEL_NAME, EMBEDDING_MODEL, EnvironmentError, SEGMENTATION_MODEL, + UsageError, VAD_MODEL, findModel, } from '@ailoud/core'; @@ -14,12 +18,16 @@ import { blocksReadiness, chooseModel, collectRemedies, + completionsPlanLines, describeAction, describePlan, formatBytes, isInteractive, + configuredModelName, + isSwitchingModel, planNeedsPackageManager, requireConsent, + resolveCompletionsShells, resolveModelName, runProvisioning, unfixableChecks, @@ -35,6 +43,8 @@ import { context } from './testContext.js'; import { parseConfig } from '../config.js'; import { Command } from 'commander'; import { registerSetup } from './setup.js'; +import { registerDoctor, runChecks } from './doctor.js'; +import { SHELL_TARGETS } from '../completions/shells.js'; describe('isInteractive', () => { it('is false under CI even with a real tty', () => { @@ -67,14 +77,23 @@ describe('resolveModelName', () => { expect(await resolveModelName({ model: 'tiny', interactive: false })).toBe('tiny'); }); - it('defaults to small when non-interactive and no --model', async () => { - expect(await resolveModelName({ interactive: false })).toBe('small'); + it('defaults to the catalogue default when non-interactive and no --model', async () => { + expect(await resolveModelName({ interactive: false })).toBe(DEFAULT_MODEL_NAME); }); it('rejects an unknown --model by name', async () => { await expect(resolveModelName({ model: 'huge', interactive: false })).rejects.toThrow(/huge/); }); + it('raises a UsageError naming the valid models for an unknown --model', async () => { + await expect(resolveModelName({ model: 'huge', interactive: false })).rejects.toThrow( + UsageError, + ); + await expect(resolveModelName({ model: 'huge', interactive: false })).rejects.toThrow( + /tiny, base, small, large-v3-turbo-q5_0, large-v3/, + ); + }); + it('prompts when interactive and no --model, returning the picked value', async () => { const selectImpl = vi.fn().mockResolvedValue('medium'); expect(await resolveModelName({ interactive: true, selectImpl })).toBe('medium'); @@ -94,6 +113,29 @@ describe('resolveModelName', () => { resolveModelName({ interactive: true, selectImpl, commandName: 'doctor' }), ).rejects.toThrow(/doctor cancelled/); }); + + it('falls back to defaultModel, not small, non-interactively with no --model', async () => { + // The regression this guards: a reinstall of a healthy, non-default + // model with no --model given used to fall through to "small" + // regardless of what had been running. + expect(await resolveModelName({ interactive: false, defaultModel: 'medium' })).toBe('medium'); + }); + + it('still honors an explicit --model over defaultModel', async () => { + expect( + await resolveModelName({ model: 'tiny', interactive: false, defaultModel: 'medium' }), + ).toBe('tiny'); + }); + + it('opens the interactive picker on defaultModel, not small', async () => { + const selectImpl = vi.fn().mockResolvedValue('medium'); + await resolveModelName({ interactive: true, selectImpl, defaultModel: 'medium' }); + expect(selectImpl).toHaveBeenCalledWith(expect.objectContaining({ initialValue: 'medium' })); + }); + + it('falls back to the catalogue default when there is no defaultModel either', async () => { + expect(await resolveModelName({ interactive: false })).toBe(DEFAULT_MODEL_NAME); + }); }); describe('chooseModel', () => { @@ -104,7 +146,7 @@ describe('chooseModel', () => { interactive: true, selectImpl, }); - expect(name).toBe('small'); + expect(name).toBe(DEFAULT_MODEL_NAME); expect(selectImpl).not.toHaveBeenCalled(); }); @@ -115,7 +157,7 @@ describe('chooseModel', () => { interactive: true, selectImpl, }); - expect(name).toBe('small'); + expect(name).toBe(DEFAULT_MODEL_NAME); expect(selectImpl).not.toHaveBeenCalled(); }); @@ -141,6 +183,26 @@ describe('chooseModel', () => { expect(name).toBe('tiny'); expect(selectImpl).not.toHaveBeenCalled(); }); + + it('forwards defaultModel through to resolveModelName, non-interactively', async () => { + const name = await chooseModel({ + remedies: [{ kind: 'download-model', slot: 'transcription' }], + interactive: false, + defaultModel: 'medium', + }); + expect(name).toBe('medium'); + }); + + it('opens the picker on defaultModel when one is given', async () => { + const selectImpl = vi.fn().mockResolvedValue('medium'); + await chooseModel({ + remedies: [{ kind: 'download-model', slot: 'transcription' }], + interactive: true, + selectImpl, + defaultModel: 'medium', + }); + expect(selectImpl).toHaveBeenCalledWith(expect.objectContaining({ initialValue: 'medium' })); + }); }); describe('requireConsent', () => { @@ -180,6 +242,118 @@ describe('requireConsent', () => { }); }); +describe('resolveCompletionsShells', () => { + const zsh = SHELL_TARGETS.find((target) => target.shell === 'zsh')!; + const bash = SHELL_TARGETS.find((target) => target.shell === 'bash')!; + let announce: Mock; + + beforeEach(() => { + for (const fn of Object.values(clack)) fn.mockReset(); + clack.isCancel.mockReturnValue(false); + announce = vi.fn(); + }); + + it('honours --completions without prompting', async () => { + expect(await resolveCompletionsShells({ completions: true }, true, [zsh], announce)).toEqual([ + zsh, + ]); + expect(clack.confirm).not.toHaveBeenCalled(); + }); + + it('honours --no-completions without prompting', async () => { + expect(await resolveCompletionsShells({ completions: false }, true, [zsh], announce)).toEqual( + [], + ); + expect(clack.confirm).not.toHaveBeenCalled(); + }); + + it('installs nothing for --yes alone, even while interactive: --yes only means "do not prompt"', async () => { + // Same rule, for the same reason, as resolveAllowShell on mcp install: + // resolving an unasked question as yes would append lines to a user's + // shell startup file in CI on the strength of a flag that says nothing + // about shell configuration. + expect(await resolveCompletionsShells({ yes: true }, true, [zsh], announce)).toEqual([]); + expect(clack.confirm).not.toHaveBeenCalled(); + }); + + it('does not prompt, and installs nothing, when no shell was detected', async () => { + expect(await resolveCompletionsShells({}, true, [], announce)).toEqual([]); + expect(clack.confirm).not.toHaveBeenCalled(); + }); + + it('does not prompt non-interactively either, with neither flag given', async () => { + expect(await resolveCompletionsShells({}, false, [zsh], announce)).toEqual([]); + expect(clack.confirm).not.toHaveBeenCalled(); + }); + + it('asks when interactive with neither flag given, and returns the detected shells on yes', async () => { + clack.confirm.mockResolvedValue(true); + expect(await resolveCompletionsShells({}, true, [zsh], announce)).toEqual([zsh]); + expect(clack.confirm).toHaveBeenCalledOnce(); + }); + + it('names the shells it will act on, not all three', async () => { + // The question used to read "(bash, zsh, fish)" and then install the + // DETECTED set without saying which -- on a stock macOS box that meant + // editing ~/.zshrc and creating a ~/.bashrc the user never had. + clack.confirm.mockResolvedValue(true); + await resolveCompletionsShells({}, true, [zsh, bash], announce); + const { message } = clack.confirm.mock.calls[0]![0] as { message: string }; + expect(message).toContain('Zsh, Bash'); + expect(message).not.toContain('fish'); + }); + + it('shows what will be written before asking, and only when it asks', async () => { + clack.confirm.mockResolvedValue(true); + await resolveCompletionsShells({}, true, [zsh], announce); + expect(announce).toHaveBeenCalledOnce(); + // Before, so the user can read it while answering. + expect(announce.mock.invocationCallOrder[0]!).toBeLessThan( + clack.confirm.mock.invocationCallOrder[0]!, + ); + + announce.mockClear(); + await resolveCompletionsShells({ completions: true }, true, [zsh], announce); + await resolveCompletionsShells({ yes: true }, true, [zsh], announce); + await resolveCompletionsShells({}, false, [zsh], announce); + expect(announce).not.toHaveBeenCalled(); + }); + + it('installs nothing when the offer is declined', async () => { + clack.confirm.mockResolvedValue(false); + expect(await resolveCompletionsShells({}, true, [zsh], announce)).toEqual([]); + }); + + it('installs nothing when the offer is cancelled', async () => { + clack.confirm.mockResolvedValue(undefined); + clack.isCancel.mockReturnValueOnce(true); + expect(await resolveCompletionsShells({}, true, [zsh], announce)).toEqual([]); + }); +}); + +describe('completionsPlanLines', () => { + const places = { home: '/home/u', configHome: '/home/u/.config', userDataDir: '/home/u/.ailoud' }; + + it('says "create" for a startup file the user does not have', async () => { + // The case the offer used to hide: a stock macOS box has .zshrc and + // .bash_profile, so answering yes CREATED a ~/.bashrc that had never + // existed. The user should read that before answering, not after. + const fs = new MemFs({}); + await fs.writeTextFile('/home/u/.zshrc', ''); + const bash = SHELL_TARGETS.find((target) => target.shell === 'bash')!; + const zsh = SHELL_TARGETS.find((target) => target.shell === 'zsh')!; + const lines = await completionsPlanLines(fs, [bash, zsh], places); + expect(lines).toContain(' Bash: create /home/u/.bashrc'); + expect(lines).toContain(' Zsh: edit /home/u/.zshrc'); + }); + + it('lists no startup file for fish, which needs none', async () => { + const fish = SHELL_TARGETS.find((target) => target.shell === 'fish')!; + const lines = await completionsPlanLines(new MemFs({}), [fish], places); + expect(lines).toEqual([' Fish: create /home/u/.config/fish/completions/ailoud.fish']); + }); +}); + describe('formatBytes', () => { it('renders sub-GB sizes in MB', () => { expect(formatBytes(147_951_465)).toBe('148 MB'); @@ -443,6 +617,119 @@ describe('collectRemedies / unfixableChecks', () => { }); }); +describe('collectRemedies with a scope', () => { + const passingFfmpeg: Check = { + name: 'ffmpeg', + ok: true, + detail: 'fine', + remedy: { kind: 'install-ffmpeg' }, + }; + const passingConfigFile: Check = { name: 'config file', ok: true, detail: 'present' }; + const failingFfprobe: Check = { + name: 'ffprobe', + ok: false, + detail: 'gone', + remedy: { kind: 'install-ffmpeg' }, + }; + const passingTranscriptionModel: Check = { + name: 'whisper model', + ok: true, + detail: '/data/models/ggml-small.bin', + remedy: { kind: 'download-model', slot: 'transcription' }, + }; + const passingVadModel: Check = { + name: 'vad model', + ok: true, + detail: '/data/models/ggml-silero-v5.1.2.bin', + remedy: { kind: 'download-model', slot: 'vad' }, + optional: true, + }; + + it('is unchanged with no scope at all: only failing checks contribute', () => { + const checks = [passingFfmpeg, passingConfigFile, failingFfprobe]; + expect(collectRemedies(checks)).toEqual([{ kind: 'install-ffmpeg' }]); + }); + + it('is unchanged with an empty scope object', () => { + const checks = [passingFfmpeg, passingConfigFile, failingFfprobe]; + expect(collectRemedies(checks, {})).toEqual([{ kind: 'install-ffmpeg' }]); + }); + + it('force takes remedies from passing checks too, but still skips checks that carry none', () => { + const checks = [passingFfmpeg, passingConfigFile, failingFfprobe]; + // passingConfigFile has no remedy at all, so it contributes nothing even + // though force takes every passing check's remedy: there is none to take. + expect(collectRemedies(checks, { force: true })).toEqual([ + { kind: 'install-ffmpeg' }, + { kind: 'install-ffmpeg' }, + ]); + }); + + it('switchingModel takes the passing transcription download-model remedy, and nothing else that was passing', () => { + const checks = [passingTranscriptionModel, passingVadModel, passingFfmpeg]; + expect(collectRemedies(checks, { switchingModel: true })).toEqual([ + { kind: 'download-model', slot: 'transcription' }, + ]); + }); + + it('switchingModel still takes every failing check too, same as no scope', () => { + const checks = [passingTranscriptionModel, failingFfprobe]; + expect(collectRemedies(checks, { switchingModel: true })).toEqual([ + { kind: 'download-model', slot: 'transcription' }, + { kind: 'install-ffmpeg' }, + ]); + }); +}); + +describe('isSwitchingModel', () => { + it('is false when the configured file is the model already named', () => { + expect(isSwitchingModel('small', '/data/models/ggml-small.bin')).toBe(false); + }); + + it('is true when a different model is named', () => { + expect(isSwitchingModel('medium', '/data/models/ggml-small.bin')).toBe(true); + }); + + it('compares by filename, so a configured path in an unusual directory still matches', () => { + expect(isSwitchingModel('small', '/some/unusual/path/ggml-small.bin')).toBe(false); + expect(isSwitchingModel('medium', '/some/unusual/path/ggml-small.bin')).toBe(true); + }); + + it('is false with no --model at all', () => { + expect(isSwitchingModel(undefined, '/data/models/ggml-small.bin')).toBe(false); + }); + + it('is false with nothing configured yet, regardless of --model', () => { + expect(isSwitchingModel('small', null)).toBe(false); + }); + + it('treats an unrecognized name as a switch, leaving the actual validation to resolveModelName', () => { + expect(isSwitchingModel('huge', '/data/models/ggml-small.bin')).toBe(true); + }); +}); + +describe('configuredModelName', () => { + // `medium` is deliberately the example here: it is RETIRED, so these cases + // also pin the guarantee that a retired model installed on a machine is + // still recognised as itself. Narrow this to the offered list and + // `setup --force` starts silently replacing a healthy `medium`. + it('names the catalogue entry matching the configured path', () => { + expect(configuredModelName('/data/models/ggml-medium.bin')).toBe('medium'); + }); + + it('matches by filename, so an unusual directory still resolves', () => { + expect(configuredModelName('/some/unusual/path/ggml-medium.bin')).toBe('medium'); + }); + + it('is undefined with nothing configured', () => { + expect(configuredModelName(null)).toBeUndefined(); + }); + + it('is undefined for a path matching no catalogue entry', () => { + expect(configuredModelName('/data/models/not-a-real-model.bin')).toBeUndefined(); + }); +}); + // executePlan outcome accounting -- mocked providers, no real download, // package-manager invocation, or network request. Only create-directory // touches real disk, and only under a throwaway temp directory. @@ -457,6 +744,11 @@ const providers = vi.hoisted(() => ({ detectPackageManager: vi.fn(), installWhisper: vi.fn(), installSherpa: vi.fn(), + // Mocked even though no existing test needs it, so that a real regression + // in the --force / substitute-remedy distinction (see doctor.ts's + // checkLanguageModel) fails an assertion instead of attempting a real + // brew-install/download of llama.cpp from inside a unit test. + installLlama: vi.fn(), downloadFile: vi.fn(), runInteractive: vi.fn(), run: vi.fn(), @@ -1002,6 +1294,9 @@ describe('runProvisioning', () => { }, }, llm: parseConfig(null).llm, + resources: parseConfig(null).resources, + audio: parseConfig(null).audio, + update: parseConfig(null).update, }; /** A failing check carrying `remedy`, shaped the way runChecks would emit it. */ @@ -1040,10 +1335,13 @@ describe('runProvisioning', () => { tmp = await mkdtemp(join(tmpdir(), 'ailoud-provisioning-test-')); paths = { configFile: join(tmp, 'config.yaml'), + configHome: tmp, dataDir: join(tmp, 'data'), dbFile: join(tmp, 'data', 'ailoud.db'), mediaRoot: join(tmp, 'data', 'media'), + jobsDir: join(tmp, 'data', 'jobs'), isProjectLibrary: false, + userDataDir: join(tmp, 'data'), }; await mkdir(paths.mediaRoot, { recursive: true }); for (const fn of Object.values(providers)) fn.mockReset(); @@ -1085,7 +1383,10 @@ describe('runProvisioning', () => { // "the run as a whole failed". The whisper model is genuinely required, // so it is what this case turns on. providers.downloadFile.mockImplementation(async (url: string, target: string) => { - if (url.includes('ggml-small') || url.includes('ggml-base')) { + // Derived from the catalogue, not spelled out: naming the file meant + // that changing DEFAULT_MODEL_NAME made this mock fail nothing at all, + // and the case passed by resolving instead of rejecting. + if (url.includes(findModel(DEFAULT_MODEL_NAME)!.file) || url.includes('ggml-base')) { throw new Error('network down'); } await mkdir(dirname(target), { recursive: true }); @@ -1191,7 +1492,10 @@ describe('runProvisioning', () => { expect(shownAtConsent).toContain(' Runs: sudo apt-get update'); expect(shownAtConsent).toContain(' Runs: sudo apt-get install -y ffmpeg'); expect(providers.runInteractive).not.toHaveBeenCalled(); - expect(ctx.lines.at(-1)).toBe('Nothing was changed.'); + // Now routed through `ui.warn` (declining consent is a "nothing + // happened" outcome), which PlainUi renders with its "warning: " + // marker prefix. + expect(ctx.lines.at(-1)).toBe('warning: Nothing was changed.'); } finally { if (isTtyDescriptor === undefined) delete (process.stdin as { isTTY?: boolean }).isTTY; else Object.defineProperty(process.stdin, 'isTTY', isTtyDescriptor); @@ -1218,7 +1522,10 @@ describe('runProvisioning', () => { await expect(runProvisioning(ctx, {}, checks, 'linux')).rejects.toThrow(EnvironmentError); - expect(ctx.lines.at(-1)).toBe('Nothing was changed.'); + // Now routed through `ui.warn` (declining consent is a "nothing + // happened" outcome), which PlainUi renders with its "warning: " + // marker prefix. + expect(ctx.lines.at(-1)).toBe('warning: Nothing was changed.'); expect(providers.runInteractive).not.toHaveBeenCalled(); expect(providers.downloadFile).not.toHaveBeenCalled(); } finally { @@ -1337,6 +1644,601 @@ describe('runProvisioning', () => { await expect(runProvisioning(ctx, { yes: true }, checks, 'linux')).resolves.toBeUndefined(); }); + + describe('--force', () => { + it('does nothing extra without it: a passing check plans nothing, same as before this option existed', async () => { + const ctx = provisioningContext(badConfig); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: 'healthy', + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + await expect(runProvisioning(ctx, { yes: true }, checks, 'linux')).resolves.toBeUndefined(); + + expect(providers.downloadFile).not.toHaveBeenCalled(); + expect(ctx.lines.at(-1)).toBe('Everything ailoud needs is already in place.'); + }); + + it('builds a non-empty plan on an all-green environment, instead of "already in place"', async () => { + // The point of --force: this is the exact fixture the test right above + // uses (a single PASSING check carrying a remedy), and the only + // difference is the option -- proof the flag, not some other change in + // the checks, is what widens the plan. + providers.downloadFile.mockImplementation(async (_url: string, target: string) => { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'dummy-model-bytes'); + }); + const ctx = provisioningContext(badConfig); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: 'healthy', + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + await expect( + runProvisioning(ctx, { yes: true, force: true }, checks, 'linux'), + ).resolves.toBeUndefined(); + + expect(providers.downloadFile).toHaveBeenCalled(); + expect(ctx.lines).not.toContain('Everything ailoud needs is already in place.'); + }); + + it('reinstalls the configured model, not the default, when no --model is given', async () => { + // The regression this guards: --force with no --model used to resolve + // through chooseModel with no defaultModel, which falls back to + // DEFAULT_MODEL_NAME ("small") regardless of what was configured -- + // silently downgrading a healthy "medium" install to "small". + const configuredModelPath = join(tmp, 'ggml-medium.bin'); + await writeFile(configuredModelPath, 'the existing medium model', 'utf8'); + const downloadedUrls: string[] = []; + providers.downloadFile.mockImplementation(async (url: string, target: string) => { + downloadedUrls.push(url); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'reinstalled model bytes'); + }); + + const ctx = provisioningContext({ + ...badConfig, + stt: { + ...badConfig.stt, + whisperCpp: { ...badConfig.stt.whisperCpp, model: configuredModelPath }, + }, + }); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: configuredModelPath, + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + await expect( + runProvisioning(ctx, { yes: true, force: true }, checks, 'linux'), + ).resolves.toBeUndefined(); + + expect(downloadedUrls.some((url) => url.includes('ggml-medium.bin'))).toBe(true); + expect(downloadedUrls.some((url) => url.includes('ggml-small.bin'))).toBe(false); + }); + + it('keeps an unrecognized configured model alone, and says why, instead of silently switching to the default', async () => { + // The regression this guards: someone who built whisper.cpp themselves + // and pointed stt.whisperCpp.model at their own file -- exactly the + // person likely to reach for --force after a corrupt download. There is + // no catalogue name for that path, and falling back to "small" would + // replace it without being asked, the same silent-switch failure the + // test above exists for. + const customModelPath = join(tmp, 'my-own-whisper-build.bin'); + await writeFile(customModelPath, 'a hand-built model', 'utf8'); + const ctx = provisioningContext({ + ...badConfig, + stt: { + ...badConfig.stt, + whisperCpp: { ...badConfig.stt.whisperCpp, model: customModelPath }, + }, + }); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: customModelPath, + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + await expect( + runProvisioning(ctx, { yes: true, force: true }, checks, 'linux'), + ).resolves.toBeUndefined(); + + expect(providers.downloadFile).not.toHaveBeenCalled(); + const output = ctx.lines.join('\n'); + expect(output).toContain(customModelPath); + expect(output).toMatch(/does not match any ailoud catalogue name/); + }); + }); + + describe('switching models', () => { + it('names the old model file after a real switch, without deleting it', async () => { + // The download writes the new model under its own filename rather than + // overwriting the old one (see provisionRunner.ts), so the previous + // .bin is still on disk afterwards -- this is what proves it is named + // rather than silently orphaned. + const oldModelPath = join(tmp, 'ggml-small.bin'); + await writeFile(oldModelPath, 'old model', 'utf8'); + providers.downloadFile.mockImplementation(async (_url: string, target: string) => { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'new model bytes'); + }); + + const ctx = provisioningContext({ + ...badConfig, + stt: { + ...badConfig.stt, + whisperCpp: { ...badConfig.stt.whisperCpp, model: oldModelPath }, + }, + }); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: oldModelPath, + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + // No --force here: naming a different model than the one configured is + // what triggers this on its own. + await expect( + runProvisioning(ctx, { yes: true, model: 'medium' }, checks, 'linux'), + ).resolves.toBeUndefined(); + + const output = ctx.lines.join('\n'); + expect(output).toContain(oldModelPath); + expect(output).toMatch(/does not delete it automatically/); + await expect(stat(oldModelPath)).resolves.toBeDefined(); + }); + + it('says nothing about an old file when the model did not actually change', async () => { + const modelPath = join(tmp, 'ggml-small.bin'); + await writeFile(modelPath, 'the model', 'utf8'); + const ctx = provisioningContext({ + ...badConfig, + stt: { ...badConfig.stt, whisperCpp: { ...badConfig.stt.whisperCpp, model: modelPath } }, + }); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: modelPath, + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + // --model names the same model that is already configured: force is + // what would still act on it, not a name that names nothing new. + await expect( + runProvisioning(ctx, { yes: true, model: 'small' }, checks, 'linux'), + ).resolves.toBeUndefined(); + + expect(providers.downloadFile).not.toHaveBeenCalled(); + expect(ctx.lines.join('\n')).not.toMatch(/does not delete it automatically/); + }); + + it('names an orphaned file even when the model NAME did not change, only its directory', async () => { + // A --force reinstall of the identical model name still moves the file: + // every download lands under dataDir/models (see provisionRunner.ts), + // so a configured path anywhere else is orphaned even though its + // basename matches the new one exactly. Comparing basenames alone (the + // original bug) missed this -- only comparing resolved paths catches it. + const oldModelPath = join(tmp, 'custom-location', 'ggml-small.bin'); + await mkdir(dirname(oldModelPath), { recursive: true }); + await writeFile(oldModelPath, 'old model', 'utf8'); + providers.downloadFile.mockImplementation(async (_url: string, target: string) => { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'new model bytes'); + }); + + const ctx = provisioningContext({ + ...badConfig, + stt: { + ...badConfig.stt, + whisperCpp: { ...badConfig.stt.whisperCpp, model: oldModelPath }, + }, + }); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: oldModelPath, + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + await expect( + runProvisioning(ctx, { yes: true, force: true }, checks, 'linux'), + ).resolves.toBeUndefined(); + + const output = ctx.lines.join('\n'); + expect(output).toContain(oldModelPath); + expect(output).toMatch(/does not delete it automatically/); + }); + }); + + describe('doctor --fix does not switch models', () => { + it('leaves a healthy, differently-named model alone under --model, unlike setup', async () => { + const modelPath = join(tmp, 'ggml-small.bin'); + await writeFile(modelPath, 'the model', 'utf8'); + const ctx = provisioningContext({ + ...badConfig, + stt: { ...badConfig.stt, whisperCpp: { ...badConfig.stt.whisperCpp, model: modelPath } }, + }); + const checks: readonly Check[] = [ + { + name: 'whisper model', + ok: true, + detail: modelPath, + remedy: { kind: 'download-model', slot: 'transcription' }, + }, + ]; + + // 'doctor', not the default 'setup': doctor --fix's own description + // promises to act only on what actually failed, and its --model help + // text promises to name what a MISSING model downloads as -- neither + // promise allows switching a model that is already healthy. + await expect( + runProvisioning(ctx, { yes: true, model: 'medium' }, checks, 'linux', 'doctor'), + ).resolves.toBeUndefined(); + + expect(providers.downloadFile).not.toHaveBeenCalled(); + expect(ctx.lines.at(-1)).toBe('Everything ailoud needs is already in place.'); + }); + }); + + describe('an unknown --model is rejected before "nothing to fix" can hide it', () => { + // The regression this guards: chooseModel/resolveModelName -- where + // --model is actually validated -- is only ever reached once remedies is + // non-empty. On an all-green machine remedies was empty regardless of + // --model, so an unknown name exited 0 with "Everything ailoud needs is + // already in place" instead of ever being rejected. + const allGreenChecks: readonly Check[] = [{ name: 'database', ok: true, detail: 'fine' }]; + + it('doctor --fix --model raises UsageError even when nothing else needs fixing', async () => { + const ctx = provisioningContext(context().config); + + await expect( + runProvisioning( + ctx, + { yes: true, model: 'ailoud-test-no-such-model' }, + allGreenChecks, + 'linux', + 'doctor', + ), + ).rejects.toThrow(UsageError); + + expect(ctx.lines).not.toContain('Everything ailoud needs is already in place.'); + }); + + it('setup --model raises UsageError even when nothing else needs fixing', async () => { + const ctx = provisioningContext(context().config); + + await expect( + runProvisioning( + ctx, + { yes: true, model: 'ailoud-test-no-such-model' }, + allGreenChecks, + 'linux', + ), + ).rejects.toThrow(UsageError); + + expect(ctx.lines).not.toContain('Everything ailoud needs is already in place.'); + }); + }); + + describe('--force and the LLM checks: repair vs substitute', () => { + /** A machine that passes every check runChecks makes, so --force's plan is decided by scope alone. */ + function healthyLlmContext(llmOverrides: Partial): CliContext & { + lines: string[]; + } { + const modelPath = join(tmp, 'ggml-small.bin'); + return provisioningContext({ + ...badConfig, + stt: { + ...badConfig.stt, + whisperCpp: { ...badConfig.stt.whisperCpp, model: modelPath, vadModel: null }, + }, + llm: { ...parseConfig(null).llm, ...llmOverrides }, + }); + } + + beforeEach(async () => { + // Real files for whichever paths the real runChecks below will access + // directly (checkModel, checkLanguageModel's llama-cpp branch); every + // binary check goes through the mocked `run()` instead, so no real + // binary needs to exist. + await writeFile(join(tmp, 'ggml-small.bin'), 'the model', 'utf8'); + await mkdir(dirname(join(tmp, 'llm-model.gguf')), { recursive: true }); + // --force also pulls in the (already-passing) transcription and VAD + // model checks -- unrelated to what these two tests are about, but + // real actions all the same, so their downloads need to actually land + // on disk or the final re-check fails on ITS OWN account instead of + // isolating the one thing being tested here. + providers.downloadFile.mockImplementation(async (_url: string, target: string) => { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'dummy-model-bytes'); + }); + }); + + it('does not install llama.cpp for a healthy claude-cli machine: install-llm there is a substitute, not a repair', async () => { + const ctx = healthyLlmContext({ + provider: 'claude-cli', + claudeCli: { binary: process.execPath, model: 'sonnet', contextTokens: 1 }, + }); + const checks = await runChecks(ctx, 'linux'); + // Sanity check on the fixture itself: the claude-cli check must + // actually be passing, or this test would trivially pass for the wrong + // reason (a failing check contributing its remedy regardless of scope). + expect(checks.find((c) => c.name === 'language model')?.ok).toBe(true); + + await expect( + runProvisioning(ctx, { yes: true, force: true }, checks, 'linux'), + ).resolves.toBeUndefined(); + + expect(providers.installLlama).not.toHaveBeenCalled(); + expect(ctx.lines.join('\n')).not.toContain('llama.cpp'); + }); + + it('DOES reinstall llama.cpp and its model for a healthy local (llama-cpp) machine: that check covers exactly what install-llm repairs', async () => { + const llmModelPath = join(tmp, 'llm-model.gguf'); + await writeFile(llmModelPath, 'the local llm model', 'utf8'); + providers.installLlama.mockResolvedValue(process.execPath); + const ctx = healthyLlmContext({ + provider: 'llama-cpp', + llamaCpp: { + ...parseConfig(null).llm.llamaCpp, + binary: process.execPath, + model: llmModelPath, + }, + }); + const checks = await runChecks(ctx, 'linux'); + expect(checks.find((c) => c.name === 'language runner')?.ok).toBe(true); + expect(checks.find((c) => c.name === 'language model')?.ok).toBe(true); + + await expect( + runProvisioning(ctx, { yes: true, force: true }, checks, 'linux'), + ).resolves.toBeUndefined(); + + expect(providers.installLlama).toHaveBeenCalled(); + }); + }); + + // These pass an actual `command: Command` (registerSetup's own shape) and + // an explicit `processEnv`, so shell detection sees exactly the fixture + // seeded below rather than whatever $SHELL/rc files happen to exist on the + // machine running the suite. + describe('offering shell completions at the end of a run', () => { + const zshScriptPath = (): string => join(paths.userDataDir, 'completions', '_ailoud'); + + /** Reaches the closing runChecks re-verification, not the "nothing to fix" shortcut. */ + async function healthyRunContext(): Promise { + providers.downloadFile.mockImplementation(async (_url: string, target: string) => { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'dummy-model-bytes'); + }); + await seedDiarizationConfig(); + return provisioningContext(badConfig); + } + + function healthyChecks(): readonly Check[] { + return [ + failing('whisper model', { kind: 'download-model', slot: 'transcription' }), + failing('vad model', { kind: 'download-model', slot: 'vad' }), + ]; + } + + it('installs completions for the detected shells when --completions is given, without prompting', async () => { + const ctx = await healthyRunContext(); + await ctx.fs.writeTextFile('/home/u/.zshrc', ''); + + await runProvisioning( + ctx, + { yes: true, completions: true }, + healthyChecks(), + 'linux', + 'setup', + false, + new Command(), + { HOME: '/home/u' }, + ); + + expect(clack.confirm).not.toHaveBeenCalled(); + expect(await ctx.fs.exists(zshScriptPath())).toBe(true); + expect(await ctx.fs.readTextFile('/home/u/.zshrc')).toContain('ailoud'); + }); + + it('installs nothing when --no-completions is given, without prompting', async () => { + const ctx = await healthyRunContext(); + await ctx.fs.writeTextFile('/home/u/.zshrc', ''); + + await runProvisioning( + ctx, + { yes: true, completions: false }, + healthyChecks(), + 'linux', + 'setup', + false, + new Command(), + { HOME: '/home/u' }, + ); + + expect(clack.confirm).not.toHaveBeenCalled(); + expect(await ctx.fs.exists(zshScriptPath())).toBe(false); + }); + + it('installs nothing on --yes alone, since --yes only means "do not prompt"', async () => { + const ctx = await healthyRunContext(); + await ctx.fs.writeTextFile('/home/u/.zshrc', ''); + + await runProvisioning( + ctx, + { yes: true }, + healthyChecks(), + 'linux', + 'setup', + false, + new Command(), + { HOME: '/home/u' }, + ); + + expect(await ctx.fs.exists(zshScriptPath())).toBe(false); + }); + + it("is never reached from a call that passes no `command` -- doctor --fix's own call shape", async () => { + const ctx = await healthyRunContext(); + await ctx.fs.writeTextFile('/home/u/.zshrc', ''); + + // No `command` argument at all: the same shape doctor.ts's call uses. + await runProvisioning(ctx, { yes: true, completions: true }, healthyChecks(), 'linux'); + + expect(await ctx.fs.exists(zshScriptPath())).toBe(false); + }); + + it('reports a failed completions install as a warning instead of failing an otherwise successful run', async () => { + // The finding this guards against: `install()` used to be called with + // no try/catch, so an unwritable .zshrc (read-only, disk full) would + // propagate out of the whole withProvisioningLock callback and turn a + // fully successful, ready-environment `setup` run into a reported + // failure over an optional nicety -- the exact outcome syncCompletions + // in self.ts already exists to prevent on the other path into this code. + const ctx = await healthyRunContext(); + await ctx.fs.writeTextFile('/home/u/.zshrc', ''); + const originalWriteTextFile = ctx.fs.writeTextFile.bind(ctx.fs); + vi.spyOn(ctx.fs, 'writeTextFile').mockImplementation( + async (path: string, content: string) => { + // The completion script write, not the .zshrc write: it happens first + // inside install(), so failing it is enough to make the whole + // per-shell install throw without needing to know install()'s + // internal write order. + if (path.includes('/completions/')) { + throw new Error('ENOSPC: no space left on device'); + } + return originalWriteTextFile(path, content); + }, + ); + + // Must resolve, not reject: a failed completions install is a nicety + // failing on top of a successful setup, not a reason to report the run + // itself as failed. + await runProvisioning( + ctx, + { yes: true, completions: true }, + healthyChecks(), + 'linux', + 'setup', + false, + new Command(), + { HOME: '/home/u' }, + ); + + expect( + ctx.lines.some( + (line) => + line.startsWith('warning: could not install completions for Zsh') && + line.includes('ENOSPC'), + ), + ).toBe(true); + // The failed write must not have left a half-written script behind. + expect(await ctx.fs.exists(zshScriptPath())).toBe(false); + }); + + it('is not offered when the final re-check still finds the environment not ready', async () => { + const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const originalCi = process.env['CI']; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + delete process.env['CI']; + try { + // The mandatory (transcription) download fails; the VAD one succeeds + // -- the same fixture as "still writes the config updates that did + // succeed, still re-checks, and still throws on a partial failure" + // above, reused here to reach a failing final check. + providers.downloadFile.mockImplementation(async (url: string, target: string) => { + if (url.includes('ggml-small') || url.includes('ggml-base')) { + throw new Error('network down'); + } + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, 'dummy-model-bytes'); + }); + const ctx = provisioningContext(badConfig); + await ctx.fs.writeTextFile('/home/u/.zshrc', ''); + // Neither --yes nor --completions: if the completions question were + // reachable here, interactive + a detected shell would make it ask. + clack.confirm.mockResolvedValue(true); // answers the plan's own consent question + clack.select.mockResolvedValue('small'); // answers chooseModel's interactive picker + + await expect( + runProvisioning(ctx, {}, healthyChecks(), 'linux', 'setup', false, new Command(), { + HOME: '/home/u', + }), + ).rejects.toThrow(EnvironmentError); + + // Exactly the one consent call the plan itself needed: a second call + // would mean the completions question was asked despite the run + // having failed its own final check. + expect(clack.confirm).toHaveBeenCalledTimes(1); + expect(await ctx.fs.exists(zshScriptPath())).toBe(false); + } finally { + if (isTtyDescriptor === undefined) delete (process.stdin as { isTTY?: boolean }).isTTY; + else Object.defineProperty(process.stdin, 'isTTY', isTtyDescriptor); + if (originalCi === undefined) delete process.env['CI']; + else process.env['CI'] = originalCi; + } + }); + }); +}); + +describe('doctor --fix does not inherit --force', () => { + it('registerDoctor never registers a --force flag, so DoctorOptions.force stays undefined', () => { + const program = new Command(); + registerDoctor(program, context(), 'linux'); + const doctorCommand = program.commands.find((command) => command.name() === 'doctor'); + expect(doctorCommand?.options.some((option) => option.long === '--force')).toBe(false); + }); +}); + +describe('doctor --fix does not offer shell completions', () => { + it('registerDoctor never registers --completions, so DoctorOptions.completions stays undefined', () => { + const program = new Command(); + registerDoctor(program, context(), 'linux'); + const doctorCommand = program.commands.find((command) => command.name() === 'doctor'); + expect(doctorCommand?.options.some((option) => option.long === '--completions')).toBe(false); + expect(doctorCommand?.options.some((option) => option.long === '--no-completions')).toBe(false); + }); +}); + +describe('registerSetup: --completions is a three-state flag', () => { + it('registers both --completions and --no-completions with no default value', () => { + const program = new Command(); + registerSetup(program, context(), 'linux'); + const setupCommand = program.commands.find((command) => command.name() === 'setup'); + const completionsOption = setupCommand?.options.find( + (option) => option.long === '--completions', + ); + const noCompletionsOption = setupCommand?.options.find( + (option) => option.long === '--no-completions', + ); + expect(completionsOption).toBeDefined(); + expect(noCompletionsOption).toBeDefined(); + // No default value is the whole point: commander merges --completions and + // --no-completions onto one key, and a default here would make "neither + // flag given" indistinguishable from an explicit "yes". + expect(completionsOption?.defaultValue).toBeUndefined(); + }); }); describe('ailoud setup on Windows', () => { diff --git a/apps/cli/src/commands/setup.ts b/apps/cli/src/commands/setup.ts index 7192d8d..447687d 100644 --- a/apps/cli/src/commands/setup.ts +++ b/apps/cli/src/commands/setup.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { confirm, isCancel, select } from '@clack/prompts'; import { chooseLlm, remediesForChoice } from '../llmChoice.js'; import type { Command } from 'commander'; @@ -7,12 +7,14 @@ import { DEFAULT_MODEL_NAME, EnvironmentError, TRANSCRIPTION_MODELS, + VAD_MODEL, + findModelFile, UsageError, findModel, planDownloadBytes, planProvisioning, } from '@ailoud/core'; -import type { Action, LlmProvider, Remedy } from '@ailoud/core'; +import type { Action, Fs, LlmProvider, Remedy } from '@ailoud/core'; import { LLAMA_VERSION, SHERPA_VERSION, @@ -34,6 +36,16 @@ import type { AiloudConfig } from '../config.js'; import { NOT_READY_MESSAGE, runChecks } from './doctor.js'; import type { CliContext } from '../wiring.js'; import type { Check } from '../ui/index.js'; +import { install } from '../completions/install.js'; +import type { Places, ShellOutcome } from '../completions/install.js'; +import { describeTree } from '../completions/generate.js'; +import type { ShellTarget } from '../completions/shells.js'; +// setup.ts and selfCompletions.ts end up importing each other (selfCompletions +// imports `isInteractive` from here) -- safe for the same reason doctor.ts's +// import of `runProvisioning` is: every use on both sides happens inside a +// function body, never at module-init time, so there is no evaluation-order +// cycle for ESM to trip over. +import { parseShells, placesFor, report, rootOf } from './selfCompletions.js'; /** * Whether ailoud may prompt: a terminal on both ends, and not a CI runner. @@ -81,6 +93,17 @@ export interface ModelNameOptions { readonly interactive: boolean; readonly selectImpl?: typeof select; readonly commandName?: CommandName; + /** + * What to resolve to when `model` is absent, in place of + * `DEFAULT_MODEL_NAME` -- the catalogue name of whatever is already + * configured, when there is one. Without this, reinstalling a healthy, + * non-default model with no `--model` given (`setup --force --yes` is the + * documented way to replace a corrupted file) silently downgraded it: the + * resolution fell through to `small` regardless of what had been running, + * and the interactive picker's `initialValue` did the same, so pressing + * Enter did too. + */ + readonly defaultModel?: string; } export async function resolveModelName(options: ModelNameOptions): Promise { @@ -91,12 +114,13 @@ export async function resolveModelName(options: ModelNameOptions): Promise ({ value: model.name, label: `${model.name} (${formatBytes(model.bytes)})`, @@ -113,6 +137,8 @@ export interface ChooseModelOptions { readonly interactive: boolean; readonly selectImpl?: typeof select; readonly commandName?: CommandName; + /** See `ModelNameOptions.defaultModel`; forwarded to `resolveModelName` unchanged. */ + readonly defaultModel?: string; } /** @@ -131,9 +157,29 @@ export async function chooseModel(options: ChooseModelOptions): Promise interactive: options.interactive && needsTranscriptionModel, ...(options.selectImpl === undefined ? {} : { selectImpl: options.selectImpl }), ...(options.commandName === undefined ? {} : { commandName: options.commandName }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), }); } +/** + * The catalogue name of whatever is already configured, so a reinstall with + * no explicit `--model` keeps it rather than falling back to + * `DEFAULT_MODEL_NAME`. Filename-based for the same reason `isSwitchingModel` + * is: `configuredModel` is a path, the catalogue only knows names. + * + * Returns `undefined` for a path that matches no catalogue entry (nothing + * configured yet, or a hand-edited config pointing at a file ailoud never + * downloaded) -- callers fall back to `DEFAULT_MODEL_NAME` themselves, the + * same way they always did when nothing was configured. + */ +export function configuredModelName(configuredModel: string | null): string | undefined { + if (configuredModel === null) return undefined; + // findModelFile, not a search of the offered list: a model that is merely + // retired is still installed and still that model. Missing it here would + // make `setup --force` fall through to DEFAULT_MODEL_NAME and replace it. + return findModelFile(basename(configuredModel))?.name; +} + export interface ConsentOptions { readonly yes: boolean; readonly interactive: boolean; @@ -155,7 +201,7 @@ export interface ConsentOptions { } /** - * Consent for installing software and downloading up to 1.5 GB. + * Consent for installing software and downloading up to 3.1 GB. * * Asked once for the whole plan, not once per action: a per-action prompt * teaches people to hit `y` without reading, which is worse than not asking. @@ -400,22 +446,66 @@ export function blocksReadiness(check: Check): boolean { } /** - * The remedies of the checks that failed -- the single definition of "what - * provisioning should act on", shared by `setup` and `doctor --fix`. + * Widens which passing checks contribute their remedy, on top of every + * failing check's (which always contributes -- see `collectRemedies`). + * + * The two flags are independent because they answer different questions: + * `force` is "reinstall everything, I asked for it explicitly"; `switchingModel` + * is "one specific thing changed, act on just that". A `--force --model medium` + * run sets both -- `force` alone would already cover what `switchingModel` + * asks for, but leaving `switchingModel` out of that run would be relying on + * `force`'s breadth by accident rather than by the actual reason the model + * remedy is present. + */ +export interface RemedyScope { + /** Take every repairable check's remedy, not only the failing ones. */ + readonly force?: boolean; + /** Take the transcription-model remedy even from a passing check. */ + readonly switchingModel?: boolean; +} + +/** + * The remedies provisioning should act on -- the single definition of "what + * to do", shared by `setup` and `doctor --fix`. * * Both entry points used to keep a verbatim copy of this filter. That is the * exact drift the one-engine design exists to prevent, so it lives here and * `runProvisioning` is the only caller. * + * With no `scope` (or both flags false/absent), only failing checks + * contribute -- the original behaviour, unchanged, and what `doctor --fix` + * still gets since it never sets `force`. + * + * `scope.force` takes a passing check's remedy too, for every check that + * carries one -- including `install-ffmpeg` and the macOS brew route for + * whisper. The user asked for the widest scope by passing `--force`; this is + * the one place that scope is decided, so `--force` cannot drift into a + * second installation route that skips some remedies `setup`'s normal path + * would have used. + * + * `scope.switchingModel` takes only the transcription `download-model` + * remedy from a passing check, and only that one: `--model ` on a + * machine whose configured model is already healthy must still switch + * models, without also reinstalling ffmpeg or whisper just because a + * transcription-model check happened to pass alongside them. + * * Deliberately NOT filtered by `blocksReadiness`: an optional check's * remedy belongs in the plan just as much as a mandatory one's -- `setup` * provisioning the diarizer alongside everything else is exactly what an * optional check being fixable is for. Only the ready/not-ready decision * itself treats the two differently. */ -export function collectRemedies(checks: readonly Check[]): readonly Remedy[] { +export function collectRemedies(checks: readonly Check[], scope?: RemedyScope): readonly Remedy[] { return checks - .filter((check) => !check.ok) + .filter((check) => { + if (!check.ok) return true; + if (scope?.force === true) return true; + return ( + scope?.switchingModel === true && + check.remedy?.kind === 'download-model' && + check.remedy.slot === 'transcription' + ); + }) .flatMap((check) => (check.remedy !== undefined ? [check.remedy] : [])); } @@ -436,15 +526,15 @@ export function unfixableChecks(checks: readonly Check[]): readonly Check[] { /** Names the checks provisioning will not touch, with the human fix each carries. */ function reportUnfixable(context: CliContext, checks: readonly Check[]): void { - context.write( + context.ui.warn( checks.length === 1 ? 'One check failed, and it is not something ailoud can repair automatically:' : `${checks.length} checks failed, and none of them are something ailoud can repair ` + 'automatically:', ); for (const check of checks) { - context.write(`FAILED ${check.name} -- ${check.detail}`); - if (check.fix !== undefined) context.write(` ${check.fix}`); + context.ui.warn(`FAILED ${check.name} -- ${check.detail}`); + if (check.fix !== undefined) context.ui.warn(` ${check.fix}`); } } @@ -453,6 +543,196 @@ export interface SetupOptions { readonly model?: string; readonly llm?: string; readonly llmModel?: string; + /** + * `doctor --fix` inherits this field (`DoctorOptions extends SetupOptions`) + * but `registerDoctor` never registers a `--force` flag, so it stays + * `undefined` there -- `doctor --fix` keeps acting only on what actually + * failed, exactly as before this option existed. + */ + readonly force?: boolean; + /** + * Set by --completions, cleared by --no-completions, absent when neither + * was given -- registerSetup registers both flags with no default so + * commander preserves this three-state shape; see + * `resolveCompletionsShells`'s doc comment for why "absent" cannot mean + * "yes". `doctor --fix` also inherits this field (`DoctorOptions extends + * SetupOptions`), but never registers either flag and never passes a + * `command` to `runProvisioning`, so the completions offer is never + * reached from there regardless of this value -- see runProvisioning's + * closing block. + */ + readonly completions?: boolean; +} + +/** + * Whether `--model ` names a different model from the one already + * configured, i.e. whether provisioning should switch rather than leave a + * healthy transcription model alone. + * + * `configuredModel` is a filesystem path (`config.stt.whisperCpp.model`); + * `model` is a catalogue name (`--model`). They are compared by filename, + * via `findModel(model).file` against the basename of `configuredModel` -- + * the two are not otherwise comparable, and the configured path's directory + * is `dataDir`-dependent and not part of the model's identity. + * + * An unrecognized `model` counts as switching (returns `true`) rather than + * `false`: this function only decides whether the transcription remedy is + * worth taking from a passing check, it never validates the name itself -- + * `resolveModelName` (via `chooseModel`) does that and raises the real + * `UsageError` naming the valid models. Returning `false` here for a bad name + * would risk `collectRemedies` finding nothing to do on an otherwise healthy + * machine, short-circuiting to "already in place" before that validation is + * ever reached. + */ +export function isSwitchingModel( + model: string | undefined, + configuredModel: string | null, +): boolean { + if (model === undefined || configuredModel === null) return false; + const found = findModel(model); + if (found === undefined) return true; + return basename(configuredModel) !== found.file; +} + +/** + * The shells to install completions for at the very end of a successful + * `setup` run, or empty to install none. Called from `offerCompletions`, + * itself called from the very end of `runProvisioning` -- see both doc + * comments for where this sits in the pipeline and why. + * + * `--completions` / `--no-completions` (registerSetup's three-state option -- + * see `SetupOptions.completions`) answer directly, with no prompt. + * + * Absent either flag, `--yes` alone answers no, without a prompt, the same + * rule and for the same reason as `resolveAllowShell` in mcpInstall.ts: + * `--yes` means "do not prompt", not "consent to everything" -- resolving + * this unasked question as yes would append lines to a user's shell startup + * file in CI on the strength of a flag that says nothing about shell + * configuration. + * + * This is deliberately the OPPOSITE of `--yes` on `self completions install` + * (see `chooseShells` in selfCompletions.ts): running that command IS the + * request to install, so there is no unasked question there for `--yes` to + * misread. The two rules must not be unified -- the asymmetry is the point, + * not a drift to fix. + * + * `detected` empty also answers no, without a prompt, regardless of every + * flag above: there is nothing useful to offer, and a question with no + * options is worse than staying silent. + */ +export async function resolveCompletionsShells( + options: SetupOptions, + interactive: boolean, + detected: readonly ShellTarget[], + announce: () => void, +): Promise { + if (options.completions === false || detected.length === 0) return []; + if (options.completions === true) return detected; + if (options.yes === true || !interactive) return []; + // The exact files, before the question rather than after it, the same shape + // `mcp install` uses before asking about an allow-list. The question used to + // name all three shells and then install the DETECTED ones without saying + // which: on a stock macOS box with .zshrc and .bash_profile, yes edited + // ~/.zshrc and CREATED a ~/.bashrc the user had never had, with no chance to + // see that first. Announced here, inside the only branch that prompts, so + // the flag and non-interactive paths stay silent. + announce(); + const answer = await confirm({ + message: `Install shell completions for ${detected.map((t) => t.label).join(', ')}?`, + initialValue: true, + }); + if (isCancel(answer) || answer !== true) return []; + return detected; +} + +/** + * One line per file the offer above would write, saying whether it exists. + * + * "create" is the word that matters: a `~/.bashrc` that is not there yet gets + * made, and a user who only ever had `~/.bash_profile` should read that before + * answering, not discover it afterwards. + * + * Deliberately a listing and not a multiselect: the plan keeps `setup`'s offer + * a single question, and `self completions install` is where a user picks + * shells one by one. This only makes the one question honest about its scope. + */ +export async function completionsPlanLines( + fs: Fs, + detected: readonly ShellTarget[], + places: Places, +): Promise { + const lines: string[] = []; + for (const target of detected) { + const rcPath = target.rcPath(places.home); + const paths = [target.scriptPath(places.home, places.configHome, places.userDataDir)]; + if (rcPath !== null) paths.push(rcPath); + for (const path of paths) { + lines.push(` ${target.label}: ${(await fs.exists(path)) ? 'edit' : 'create'} ${path}`); + } + } + return lines; +} + +/** + * Offers to install shell completions -- the very last thing a successful + * `setup` run does. Called only from the closing block of `runProvisioning`, + * after the final `runChecks` there has confirmed the environment is ready: + * a run that failed to provision must not finish by asking about a nicety. + * + * `command` is the running invocation's own command, handed down so the + * completion script can be rendered from the live command tree the same way + * `self completions install` renders it (see `rootOf`/`describeTree`). + * `runProvisioning` only calls this when `command` was supplied, which today + * is only true for `setup` itself -- `doctor --fix` shares this whole + * pipeline but never passes one, since "the environment doctor --fix just + * repaired" is not the same moment as "the machine setup just finished + * provisioning for the first time", and doctor --fix's own description makes + * no promise about shell completions. + */ +async function offerCompletions( + context: CliContext, + options: SetupOptions, + interactive: boolean, + command: Command, + processEnv: NodeJS.ProcessEnv, +): Promise { + const places = placesFor(context, processEnv); + const detected = await parseShells(context, 'auto', places, processEnv); + // Resolved up front because it reads the filesystem and the callback that + // prints it runs inside the one branch that prompts, which is synchronous. + // A handful of `exists` calls on a run that never asks is not worth a + // second code path. + const lines = await completionsPlanLines(context.fs, detected, places); + const targets = await resolveCompletionsShells(options, interactive, detected, () => { + context.ui.note('Shell completions would be written to:'); + for (const line of lines) context.ui.note(line); + }); + if (targets.length === 0) return; + + const tree = describeTree(rootOf(command)); + const outcomes: ShellOutcome[] = []; + for (const target of targets) { + try { + outcomes.push(await install(context.fs, target, tree, places)); + } catch (error) { + // Completions are a convenience layered on top of everything this run + // just provisioned -- ffmpeg, whisper.cpp, and possibly a + // multi-gigabyte model -- not the provisioning itself. `syncCompletions` + // in self.ts enforces the identical rule on the other path into this + // code (self update -> refreshCompletions), with the same reasoning: an + // unwritable shell startup file (read-only .zshrc, a full disk) must not + // turn an otherwise fully successful run into a reported failure. + // Caught per shell rather than around the whole loop, unlike + // syncCompletions's single try/catch -- one unwritable rc file must not + // also skip bash and fish, which are independent writes that would + // otherwise have succeeded. + context.ui.warn( + `could not install completions for ${target.label}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + } + report(context, outcomes); } /** @@ -480,36 +760,103 @@ export async function runProvisioning( platform: NodeJS.Platform = process.platform, commandName: CommandName = 'setup', checksAlreadyShown: boolean = false, + /** + * The running invocation's own command, forwarded only by `registerSetup` + * -- see `offerCompletions`'s doc comment for why its absence is what + * keeps `doctor --fix` from ever reaching the completions offer. + */ + command?: Command, + /** Injected so a test can pin what shell detection sees without touching the real environment. */ + processEnv: NodeJS.ProcessEnv = process.env, ): Promise { // Before anything else, and in particular before any remedy is collected // or plan built: building a plan on Windows would take consent, pull down - // up to 1.6 GB of models, then fail both installs and exit non-zero + // up to 3.1 GB of models, then fail both installs and exit non-zero // anyway. This used to live only in registerSetup, so `doctor --fix` on // Windows built the plan and paid for the download before failing -- // exactly the drift the shared engine exists to prevent. Living here // means both entry points refuse first and spend nothing, and neither can // drift away from it again. if (platform === 'win32') { - for (const line of windowsManualSteps(commandName)) context.write(line); + for (const line of windowsManualSteps(commandName)) context.ui.content(line); throw new EnvironmentError( `ailoud ${commandName} cannot provision Windows: follow the manual steps above.`, ); } + // Validated here, unconditionally, rather than left to chooseModel further + // down: that call is never reached when remedies end up empty, which on an + // all-green machine used to mean an unknown --model exited 0 with + // "Everything ailoud needs is already in place" instead of ever being + // rejected -- a typo must fail the same way regardless of what else is or + // is not broken. resolveModelName already owns this validation (round 1's + // note not to duplicate it still applies); called here only for the + // UsageError it throws on a bad name -- `interactive: false` is inert + // because the validating branch returns before touching it. + if (options.model !== undefined) { + await resolveModelName({ model: options.model, interactive: false }); + } + const interactive = isInteractive(process.env, process.stdin.isTTY === true); + // The path `--model` would replace, read before anything downloads: it is + // both what decides `switchingModel` below and, after a successful switch, + // the file `writeConfigUpdates` is about to orphan (see the note printed + // near the end of this function). + const configuredModel = context.config.stt.whisperCpp.model; + const scope: RemedyScope = { + force: options.force === true, + // `setup` only, deliberately: `doctor --fix`'s own description promises + // to "provision anything that failed a check", and its `--model` help + // text promises the same ("to download if one is needed") -- switching a + // healthy model out from under `--fix` would break both promises for a + // command nobody asked to change anything with. + switchingModel: commandName === 'setup' && isSwitchingModel(options.model, configuredModel), + }; // Asked before the "nothing to fix" test below, not after: choosing a hosted // engine REMOVES the local install and download from the list, so the answer // can be the difference between a plan and an empty one. - const collected = collectRemedies(checks); + const collected = collectRemedies(checks, scope); const llmChoice = await chooseLlm({ ...(options.llm === undefined ? {} : { llm: options.llm }), ...(options.llmModel === undefined ? {} : { llmModel: options.llmModel }), remedies: collected, interactive, commandName, - note: (message) => context.write(message), + note: (message) => context.ui.note(message), }); - const remedies = remediesForChoice(collected, llmChoice); + let remedies = remediesForChoice(collected, llmChoice); + + // `--force` with no --model, on a transcription model that already exists + // but matches no catalogue entry -- someone who built whisper.cpp + // themselves and pointed `stt.whisperCpp.model` at their own file, exactly + // the person likely to reach for `--force` after a corrupt download. There + // is no catalogue name to redownload it AS, and guessing the project + // default would silently replace a model the user chose on purpose -- the + // same silent-switch failure `configuredModelName` was added to prevent + // for --model itself. `transcriptionCheck.ok` is the guard that scopes + // this to force's widening specifically: a check that is genuinely + // failing (missing or corrupted) still needs *something* downloaded, and + // "small" for an unrecognized path is the same fallback --model has always + // had in that case -- untouched here, unrelated to what --force just + // widened in. + const transcriptionCheck = checks.find( + (check) => check.remedy?.kind === 'download-model' && check.remedy.slot === 'transcription', + ); + const unrecognizedForcedModel = + options.model === undefined && + transcriptionCheck?.ok === true && + configuredModel !== null && + configuredModelName(configuredModel) === undefined && + transcriptionCheck.remedy !== undefined && + remedies.includes(transcriptionCheck.remedy); + if (unrecognizedForcedModel) { + context.ui.note( + `Keeping the transcription model already configured at ${configuredModel} -- it does not ` + + 'match any ailoud catalogue name, so there is nothing to reinstall it as. Pass ' + + '--model to switch to a catalogue model instead.', + ); + remedies = remedies.filter((remedy) => remedy !== transcriptionCheck.remedy); + } if (remedies.length === 0) { // "Nothing to fix" and "nothing FIXABLE to fix" are different answers, @@ -519,7 +866,7 @@ export async function runProvisioning( // and nothing else. const unfixable = unfixableChecks(checks); if (unfixable.length === 0) { - context.write('Everything ailoud needs is already in place.'); + context.ui.note('Everything ailoud needs is already in place.'); return; } // `doctor --fix` already rendered the full check list (ui.checks) before @@ -530,11 +877,13 @@ export async function runProvisioning( throw new EnvironmentError(NOT_READY_MESSAGE); } + const defaultModel = configuredModelName(configuredModel); const modelName = await chooseModel({ ...(options.model === undefined ? {} : { model: options.model }), remedies, interactive, commandName, + ...(defaultModel === undefined ? {} : { defaultModel }), }); const actions = planProvisioning(remedies, { modelName }); @@ -551,11 +900,11 @@ export async function runProvisioning( manager, configFile: context.paths.configFile, }; - for (const line of describePlan(actions, env)) context.write(line); + for (const line of describePlan(actions, env)) context.ui.content(line); const consented = await requireConsent({ yes: options.yes === true, interactive, commandName }); if (!consented) { - context.write('Nothing was changed.'); + context.ui.warn('Nothing was changed.'); // Declining does not undo the checks that failed to get here: remedies // is non-empty at this point (the "nothing to fix" case above already // returned), so the environment is exactly as not-ready as it was before @@ -583,24 +932,49 @@ export async function runProvisioning( dataDir: context.paths.dataDir, manager, interactive, - onStep: (message) => context.write(message), + onStep: (message) => context.ui.note(message), // Coarse-grained on purpose: a line per percent would flood plain output, // and no spinner is used here (see provisionRunner.ts) so there is never // a live display for this to update instead. onProgress: (file, percent) => { - if (percent % 20 === 0) context.write(` ${file}: ${percent}%`); + if (percent % 20 === 0) context.ui.note(` ${file}: ${percent}%`); }, }); for (const outcome of result.outcomes) { - const status = outcome.ok ? 'ok' : 'FAILED'; - context.write(`${status} ${describeAction(outcome.action)} -- ${outcome.detail}`); + const line = `${describeAction(outcome.action)} -- ${outcome.detail}`; + if (outcome.ok) { + context.ui.success(line); + } else { + context.ui.warn(line); + } } const updatedKeys = Object.keys(result.updates); if (updatedKeys.length > 0) { await writeConfigUpdates(context.paths.configFile, result.updates); - context.write(`Updated ${context.paths.configFile}: ${updatedKeys.join(', ')}`); + context.ui.content(`Updated ${context.paths.configFile}: ${updatedKeys.join(', ')}`); + } + + // A download always targets `/models/` (see + // provisionRunner.ts's download-model branch), never the path that was + // configured before -- so the previous .bin is still sitting wherever it + // was, up to 3.1 GB ailoud has no garbage collection for and will not + // delete unasked. Named once, here, rather than silently orphaned. + // `configuredModel` is the path from BEFORE this run (captured at the top + // of this function); compared as resolved paths, not basenames, because a + // `--force` reinstall of the SAME model name still orphans a configured + // file that lived outside `/models` -- the filename matches, but + // the file writeConfigUpdates now points at is a different one on disk. + if ( + result.updates.model !== undefined && + configuredModel !== null && + resolve(configuredModel) !== resolve(result.updates.model) + ) { + context.ui.content( + `The previous transcription model is still at ${configuredModel} -- ailoud does not ` + + 'delete it automatically; remove it by hand if you no longer need it.', + ); } // Re-read unconditionally, even when result.updates was empty: an action @@ -612,6 +986,15 @@ export async function runProvisioning( if (finalChecks.some(blocksReadiness)) { throw new EnvironmentError('ailoud is still not ready: see the failing checks above.'); } + + // The very last thing a successful run does -- placed after the + // readiness throw above, not before, so a run that failed to provision + // cannot still end by asking about a nicety. See offerCompletions's doc + // comment for why `command` is undefined (and this a no-op) whenever the + // caller is `doctor --fix` rather than `setup` itself. + if (command !== undefined) { + await offerCompletions(context, options, interactive, command, processEnv); + } }); } @@ -641,9 +1024,14 @@ export function windowsManualSteps(commandName: CommandName): readonly string[] ` 2. whisper.cpp -- take the Windows assets of release ${WHISPER_TAG} from`, ' https://github.com/ggml-org/whisper.cpp/releases and extract the tree,', ' keeping it intact.', - ' 3. A transcription model -- ggml-small.bin (or another size) from', + // Both file names come from the catalogue rather than being typed here. + // The transcription one was typed here once and went stale the day the + // default changed, telling a Windows user to download a file `setup` no + // longer installs. + ` 3. A transcription model -- ${findModel(DEFAULT_MODEL_NAME)?.file ?? 'a catalogue entry'}`, + ' (or another entry from the catalogue) from', ' https://huggingface.co/ggerganov/whisper.cpp', - ' 4. The VAD model, only needed by --multilingual -- ggml-silero-v5.1.2.bin', + ` 4. The VAD model, only needed by --multilingual -- ${VAD_MODEL.file}`, ' from https://huggingface.co/ggml-org/whisper-vad', ' 5. The diarizer, only needed by --diarize -- sherpa-onnx publishes no', ' Windows asset in the pinned release, so build sherpa-onnx from source to', @@ -670,21 +1058,34 @@ export function registerSetup( program .command('setup') .option('--yes', 'confirm the plan without prompting') - .option('--model ', 'transcription model to download (default: small)') + .option( + '--model ', + // Interpolated, not spelled out: this description named `small` until the + // default changed under it, and `ailoud setup --help` then contradicted + // the tool for as long as nobody noticed. + `switch to this transcription model (default: the configured one, else ${DEFAULT_MODEL_NAME})`, + ) .option('--llm ', 'summariser to set up: local, claude-cli, claude-api, openai, skip') .option( '--llm-model ', 'model id for the chosen summariser (default: ask, or keep the configured one)', ) + .option('--force', 'reinstall even when everything checks out') + .option('--completions', 'install shell completions at the end, without asking') + .option('--no-completions', 'skip the shell-completions offer, without asking') .description('Install ffmpeg and whisper.cpp, and download the models ailoud needs') - .action(async (options: SetupOptions) => { + .action(async (options: SetupOptions, command: Command) => { await context.ui.frame('Setting up ailoud', async () => { // The win32 refusal lives in runProvisioning now (the shared // engine), not here -- see its doc comment. runChecks itself only // probes; it downloads nothing, so running it unconditionally // before that guard costs nothing on Windows either. const checks = await runChecks(context, platform); - await runProvisioning(context, options, checks, platform); + // `command` is this action's own Command instance, passed through so + // a successful run can offer shell completions off the live command + // tree -- see offerCompletions's doc comment for why only `setup` + // passes one. + await runProvisioning(context, options, checks, platform, 'setup', false, command); }); }); } diff --git a/apps/cli/src/commands/summarize.test.ts b/apps/cli/src/commands/summarize.test.ts index 1619f1e..f356d47 100644 --- a/apps/cli/src/commands/summarize.test.ts +++ b/apps/cli/src/commands/summarize.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { UsageError } from '@ailoud/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FailureError, UsageError } from '@ailoud/core'; import type { Summarizer } from '@ailoud/core'; import { buildProgram } from '../program.js'; -import { contextWithTranscript } from './testContext.js'; +import { contextWithTranscript, withRealDataDir } from './testContext.js'; import type { MemFs } from '@ailoud/core/testing'; import { transcriptBudget } from './summarize.js'; +import { createJob, getJob, listJobs } from '../jobs/store.js'; +import { withJobLock } from '../jobs/lock.js'; +import { spawnDetachedJob } from '../jobs/spawn.js'; + +vi.mock('../jobs/spawn.js', () => ({ spawnDetachedJob: vi.fn() })); const summarizer = (contextTokens: number): Summarizer => ({ name: 'fake', @@ -288,6 +293,322 @@ describe('ailoud summarize: progress', () => { }); }); +describe('ailoud summarize --job', () => { + it('is hidden from --help', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + const program = buildProgram(ctx); + const summarizeCmd = program.commands.find((c) => c.name() === 'summarize')!; + const jobOption = summarizeCmd.options.find((o) => o.long === '--job'); + expect(jobOption?.hidden).toBe(true); + }); + + it('never appears in rendered --help text either', async () => { + // The option-object check above pins commander's `hidden` flag, but not + // that commander actually honours it when rendering. Checked against the + // pinned commander version in use. + const ctx = await contextWithTranscript({ clearLines: true }); + const program = buildProgram(ctx); + const summarizeCmd = program.commands.find((c) => c.name() === 'summarize')!; + expect(summarizeCmd.helpInformation()).not.toContain('--job'); + }); + + it('rejects an id with no matching job', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--job', 'nope']), + ).rejects.toThrow(UsageError); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--job', 'nope']), + ).rejects.toThrow(/nope/); + }); + + it('reports success into the job state file, without the summary body', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'summarize', recordings: 1, declared: null }, + ); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--job', job.id]); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('done'); + expect(state?.percent).toBe(100); + expect(state?.result).toMatchObject({ reportId: expect.any(String) }); + expect(JSON.stringify(state?.result)).not.toContain('a summary'); + }); + }); + + it('reports a failure into the job state file and still rethrows, exit code unchanged', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'summarize', recordings: 1, declared: null }, + ); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'NOPE', '--job', job.id]), + ).rejects.toThrow(); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('failed'); + expect(state?.error).toBeTruthy(); + }); + }); + + it('records a failure when the job lock is already held on the way in', async () => { + // See the identical test in commands.test.ts (transcribe --job) for why: + // withJobLock itself can throw before body() ever runs, and the + // try/catch has to wrap the lock call, not just its body, to catch that. + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'summarize', recordings: 1, declared: null }, + ); + await withJobLock(ctx.paths.dataDir, async () => { + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--job', job.id]), + ).rejects.toThrow(FailureError); + }); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('failed'); + expect(state?.error).toMatch(/already running/); + }); + }); +}); + +describe('ailoud summarize --detach', () => { + afterEach(() => { + vi.mocked(spawnDetachedJob).mockReset(); + }); + + it('is not hidden from --help, unlike --job', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + const program = buildProgram(ctx); + const summarizeCmd = program.commands.find((c) => c.name() === 'summarize')!; + const detachOption = summarizeCmd.options.find((o) => o.long === '--detach'); + expect(detachOption?.hidden).toBeFalsy(); + }); + + it('rejects --detach together with --job before doing anything', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'summarize', + 'ID001', + '--detach', + '--job', + 'X', + ]), + ).rejects.toThrow(UsageError); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + }); + + it('validates an unknown --template before creating a job or spawning anything', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'summarize', + 'ID001', + '--template', + 'retrospective', + '--detach', + ]), + ).rejects.toThrow(/unknown --template "retrospective"/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('validates the id/--tag selection before creating a job or spawning anything', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', '--detach']), + ).rejects.toThrow(/needs recording ids or --tag/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('refuses when another job already holds the lock, without creating a job', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await withJobLock(ctx.paths.dataDir, async () => { + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--detach']), + ).rejects.toThrow(FailureError); + }); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('creates a running job, spawns the build args without --detach, and returns at once', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + ctx.lines.length = 0; + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--detach']); + expect(spawnDetachedJob).toHaveBeenCalledTimes(1); + const [, commandArgs, job] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual(['summarize', 'ID001']); + const state = await getJob(ctx.fs, ctx.paths.jobsDir, job.id); + expect(state?.state).toBe('running'); + expect(ctx.lines.join('\n')).toContain(job.id); + }); + }); + + it('preserves a --context value that is itself the literal string "--detach"', async () => { + // The child args used to be built by filtering process.argv for the + // string '--detach', which stripped every occurrence -- including one + // that was actually the value of --context, not the flag -- corrupting + // the child's invocation. Building from the parsed options instead + // means only the real flag is ever left out. + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'summarize', + 'ID001', + '--context', + '--detach', + '--detach', + ]); + expect(spawnDetachedJob).toHaveBeenCalledTimes(1); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual(['summarize', 'ID001', '--context', '--detach']); + }); + }); + + it('marks the job failed and rethrows when spawning itself throws', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + vi.mocked(spawnDetachedJob).mockImplementation(() => { + throw new Error('spawn boom'); + }); + const before = new Set((await listJobs(ctx.fs, ctx.paths.jobsDir)).map((j) => j.id)); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--detach']), + ).rejects.toThrow(/spawn boom/); + const after = await listJobs(ctx.fs, ctx.paths.jobsDir); + const created = after.find((job) => !before.has(job.id)); + expect(created?.state).toBe('failed'); + expect(created?.error).toContain('spawn boom'); + }); + }); +}); + +describe('ailoud summarize --max-cpu', () => { + afterEach(() => { + vi.mocked(spawnDetachedJob).mockReset(); + }); + + it.each(['0', '101', 'abc', '-5', '2.5'])( + 'refuses --max-cpu %s, naming the accepted range', + async (value) => { + const ctx = await contextWithTranscript({ clearLines: true }); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--max-cpu', value]), + ).rejects.toThrow(/1.*100/); + }, + ); + + it('accepts a --max-cpu inside the range and forwards the resulting budget to the summarizer', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--max-cpu', '50']); + // testContext's fixed topology is { logical: 10, performance: 8 }: 50% of the + // 8 performance cores, rounded, is 4. + // + // createSummarizer is called twice per `summarize` run -- once at + // summarize.ts:216 for the summarizer's name (used in a progress note), + // once inside runSummary at summarizeRun.ts:113, which is the call that + // actually does the work -- and both must carry the budget. Asserting the + // whole array, not just toContainEqual, is what makes this catch a + // dropped argument at summarizeRun.ts:113 specifically: before this test + // was tightened, that call site could fall back to createSummarizer's own + // "no budget" default of 4 threads while the name-only call at + // summarize.ts:216 still supplied a real budget, and `toContainEqual` + // against a shared array could not tell the two apart. + expect(ctx.summarizerBudgets).toEqual([ + expect.objectContaining({ threads: 4, gpu: true }), + expect.objectContaining({ threads: 4, gpu: true }), + ]); + }); + + it('does not register --denoise: summarizing reads stored transcripts, not audio', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + const program = buildProgram(ctx); + const summarizeCmd = program.commands.find((c) => c.name() === 'summarize')!; + expect(summarizeCmd.options.find((o) => o.long === '--denoise')).toBeUndefined(); + }); + + it('does not register --no-gpu: no summariser reads budget.gpu', async () => { + // --no-gpu was removed from summarize because it provably did nothing: + // llama's -ngl was deliberately dropped for want of a measurement, and + // the three hosted providers (claude-cli, anthropic, openai-compatible) + // have no GPU to disable. It stays on transcribe, where it reaches + // whisper's -ng. + const ctx = await contextWithTranscript({ clearLines: true }); + const program = buildProgram(ctx); + const summarizeCmd = program.commands.find((c) => c.name() === 'summarize')!; + expect(summarizeCmd.options.find((o) => o.long === '--no-gpu')).toBeUndefined(); + await expect( + buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--no-gpu']), + ).rejects.toThrow(/unknown option/); + }); + + it('validates --max-cpu before creating a job or spawning anything, under --detach', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await expect( + buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'summarize', + 'ID001', + '--max-cpu', + '0', + '--detach', + ]), + ).rejects.toThrow(/1.*100/); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + }); + }); + + it('forwards --max-cpu to the detached child, unmodified', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'summarize', + 'ID001', + '--max-cpu', + '50', + '--detach', + ]); + expect(spawnDetachedJob).toHaveBeenCalledTimes(1); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual(['summarize', 'ID001', '--max-cpu', '50']); + }); + }); + + it('forwards nothing to the detached child when nothing was asked for', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'summarize', 'ID001', '--detach']); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).not.toContain('--max-cpu'); + }); + }); +}); + describe('ailoud summarize --template / --context', () => { it('shapes the headings by template', async () => { const ctx = await contextWithTranscript({ clearLines: true }); diff --git a/apps/cli/src/commands/summarize.ts b/apps/cli/src/commands/summarize.ts index ff4d86a..4107e4b 100644 --- a/apps/cli/src/commands/summarize.ts +++ b/apps/cli/src/commands/summarize.ts @@ -1,12 +1,22 @@ import { join } from 'node:path'; import type { Command } from 'commander'; +import { Option } from 'commander'; import { DEFAULT_TEMPLATE, FailureError, UsageError } from '@ailoud/core'; +import type { Recording, SummaryTemplate } from '@ailoud/core'; import { page, shouldPage } from '@ailoud/providers'; import type { CliContext } from '../wiring.js'; import { resolveRecordings } from '../resolveId.js'; import { collectTag, parseTags } from '../tags.js'; +import { parseMaxCpu } from './resourceOptions.js'; import { loadTemplate, loadTemplates, templatesDir } from '../templateStore.js'; import { runSummary } from '../summarizeRun.js'; +import { JobLog } from '../jobs/log.js'; +import { JobReporter } from '../jobs/reporter.js'; +import { createJob } from '../jobs/store.js'; +import { jobBusyMessage, jobLockHolder, withJobLock } from '../jobs/lock.js'; +import { loadJob } from '../jobs/loadJob.js'; +import { jobStatePath } from '../jobs/state.js'; +import { spawnDetachedJob } from '../jobs/spawn.js'; export { transcriptBudget } from '../summarizeRun.js'; @@ -17,6 +27,79 @@ interface SummarizeOptions { readonly save?: boolean; readonly template?: string; readonly context?: string; + readonly maxCpu?: string; + readonly job?: string; + readonly detach?: boolean; +} + +interface ResolvedSummarizeRun { + readonly tags: readonly string[]; + readonly template: SummaryTemplate; + readonly recordings: readonly Recording[]; +} + +/** + * Validates and resolves everything summarize needs before any work starts: + * the id/tag selection, the template, and which recordings are in scope. + * + * Shared between the normal run and `--detach`, so a bad selection or an + * unknown template costs a usage error right here, at the prompt -- never + * after a transcript has been chunked and sent to a model. + */ +async function resolveSummarizeRun( + context: CliContext, + ids: readonly string[], + options: SummarizeOptions, +): Promise { + const tags = parseTags(options.tag ?? []); + if (ids.length === 0 && tags.length === 0) { + // Summarising the entire library by accident would be an expensive + // mistake -- minutes of local inference, or real money on a hosted + // model -- so there is no default selection here, unlike transcribe. + throw new UsageError('summarize needs recording ids or --tag; it has no default.'); + } + if (ids.length > 0 && tags.length > 0) { + throw new UsageError('summarize takes ids or --tag, not both.'); + } + // Resolved here, before anything is read or spawned: a mistyped template + // should fail in milliseconds, not after a transcript has been chunked. + // From disk, so an edited template takes effect and a template the user + // wrote is a peer of the shipped ones. + const dir = templatesDir(context.paths.configFile); + const wanted = options.template ?? DEFAULT_TEMPLATE; + const template = await loadTemplate(context.fs, dir, wanted); + if (template === undefined) { + const available = (await loadTemplates(context.fs, dir)).map((t) => t.name).join(', '); + throw new UsageError(`unknown --template "${wanted}"; choose one of: ${available}`); + } + const recordings = + ids.length > 0 + ? await resolveRecordings(context.store, ids) + : await context.store.listRecordings({ tags }); + if (recordings.length === 0) { + throw new FailureError(`No recordings carry ${tags.map((t) => `"${t}"`).join(' and ')}.`); + } + return { tags, template, recordings }; +} + +/** + * Serializes the detached child's argv from the already-parsed, already- + * validated `ids` and `options` -- never from `process.argv`. See + * transcribeChildArgs's own comment in transcribe.ts for why: raw argv can + * both lose a legitimate value that collides with the literal string + * `"--detach"` (a `--context` note, say) and cannot be trusted to still be + * `[node, script, ...args]` under every wrapper this CLI runs behind. + */ +function summarizeChildArgs(ids: readonly string[], options: SummarizeOptions): string[] { + const args: string[] = ['summarize', ...ids]; + for (const tag of options.tag ?? []) args.push('--tag', tag); + if (options.lang !== undefined) args.push('--lang', options.lang); + if (options.fresh === true) args.push('--fresh'); + if (options.save === false) args.push('--no-save'); + if (options.template !== undefined) args.push('--template', options.template); + if (options.context !== undefined) args.push('--context', options.context); + if (options.maxCpu !== undefined) args.push('--max-cpu', options.maxCpu); + return args; } export function registerSummarize(program: Command, context: CliContext): void { @@ -37,100 +120,180 @@ export function registerSummarize(program: Command, context: CliContext): void { 'a sentence or two the transcript does not say: who these people are to each other, ' + 'what the project is, what happened last week', ) + .option( + '--max-cpu ', + 'share of this machine to use, 1 to 100 (default: the configured 90)', + ) + // Hidden, and not a feature: this is how the detached child started by + // `--detach` and by the MCP server is told which job it is. A user has + // no reason to pass it, and `--help` listing it would invite exactly the + // hand-made half-registered job this avoids. + .addOption( + new Option( + '--job ', + 'report into an existing job state file instead of the terminal', + ).hideHelp(), + ) + .option('--detach', 'start the work in the background and print its job id') .description('Summarise one or several recordings with a language model') .action(async (ids: string[], options: SummarizeOptions) => { - const tags = parseTags(options.tag ?? []); - if (ids.length === 0 && tags.length === 0) { - // Summarising the entire library by accident would be an expensive - // mistake -- minutes of local inference, or real money on a hosted - // model -- so there is no default selection here, unlike transcribe. - throw new UsageError('summarize needs recording ids or --tag; it has no default.'); - } - if (ids.length > 0 && tags.length > 0) { - throw new UsageError('summarize takes ids or --tag, not both.'); - } - // Resolved here, before anything is read or spawned: a mistyped template - // should fail in milliseconds, not after a transcript has been chunked. - // From disk, so an edited template takes effect and a template the user - // wrote is a peer of the shipped ones. - const dir = templatesDir(context.paths.configFile); - const wanted = options.template ?? DEFAULT_TEMPLATE; - const template = await loadTemplate(context.fs, dir, wanted); - if (template === undefined) { - const available = (await loadTemplates(context.fs, dir)).map((t) => t.name).join(', '); - throw new UsageError(`unknown --template "${wanted}"; choose one of: ${available}`); + // The two ends of one mechanism: --job is how a detached child reports + // in, --detach is how one gets started. Naming both says two + // contradictory things about who is driving this run. + if (options.detach === true && options.job !== undefined) { + throw new UsageError('--detach cannot be combined with --job.'); } - await context.ui.frame('Summarising', async () => { - const recordings = - ids.length > 0 - ? await resolveRecordings(context.store, ids) - : await context.store.listRecordings({ tags }); - if (recordings.length === 0) { - throw new FailureError(`No recordings carry ${tags.map((t) => `"${t}"`).join(' and ')}.`); - } + // Parsed above the --detach branch, before a job file exists or + // anything is spawned -- see the identical comment in transcribe.ts. + // The budget computed here is only used by the run that happens in + // this process; the detached child parses its own argv and computes + // its own. + const budget = await context.resources({ + ...(options.maxCpu === undefined ? {} : { maxCpuPercent: parseMaxCpu(options.maxCpu) }), + }); - // The transcripts are written out for the length of the run and no - // longer. They exist as files because a long one goes to the model in - // portions and because the prompt reaches a spawned binary through a - // file rather than an argument -- an argv-sized transcript is a limit, - // not a feature. The library in the database stays the only copy that - // outlives the command. - // Named before the run so the portion note can use it; createSummarizer - // is cheap and runSummary makes its own. - const summarizerName = context.createSummarizer().name; - const runDir = await context.fs.tempDir(); + if (options.detach === true) { + // Every validation the normal run would do, run here, before the job + // file exists or anything is spawned -- see resolveSummarizeRun's + // own comment. + const resolved = await resolveSummarizeRun(context, ids, options); + const holder = await jobLockHolder(context.paths.dataDir); + if (holder !== null) { + throw new FailureError(jobBusyMessage(holder)); + } + const job = await createJob( + { + fs: context.fs, + ids: context.ids, + clock: context.clock, + jobsDir: context.paths.jobsDir, + }, + { kind: 'summarize', recordings: resolved.recordings.length, declared: null }, + ); try { - const result = await context.ui.summarising((report) => - runSummary( - context, - { - recordings, - template, - ...(options.lang === undefined ? {} : { language: options.lang }), - ...(options.context === undefined ? {} : { context: options.context }), - ...(options.fresh === true ? { fresh: true } : {}), - ...(options.save === false ? { save: false } : {}), - }, - { - onProgress: report, - onPlan: ({ reused, portions }) => { - if (reused > 0) { - context.ui.note( - `Reusing ${reused} stored ${reused === 1 ? 'summary' : 'summaries'} ` + - 'instead of transcripts (--fresh to read the transcripts again).', - ); - } - if (portions > 1) { - context.ui.note( - `Too long for ${summarizerName} in one pass: ${portions} portions, then combined.`, - ); - } + await spawnDetachedJob( + { fs: context.fs, jobsDir: context.paths.jobsDir }, + summarizeChildArgs(ids, options), + job, + ); + } catch (error) { + // The id below must always resolve: if the child never started, + // the job file must say so rather than "running" forever. + const reporter = new JobReporter({ + fs: context.fs, + jobsDir: context.paths.jobsDir, + initial: job, + log: new JobLog(job.log), + }); + await reporter.fail(error instanceof Error ? error.message : String(error)); + throw error; + } + context.ui.success( + `started job ${job.id} -- progress in ${jobStatePath(context.paths.jobsDir, job.id)}`, + ); + return; + } + + const job = await loadJob(context, options.job); + + const body = async (): Promise => { + const { template, recordings } = await resolveSummarizeRun(context, ids, options); + + return context.ui.frame('Summarising', async () => { + // The transcripts are written out for the length of the run and no + // longer. They exist as files because a long one goes to the model in + // portions and because the prompt reaches a spawned binary through a + // file rather than an argument -- an argv-sized transcript is a limit, + // not a feature. The library in the database stays the only copy that + // outlives the command. + // Named before the run so the portion note can use it; createSummarizer + // is cheap and runSummary makes its own. + const summarizerName = context.createSummarizer(budget).name; + const runDir = await context.fs.tempDir(); + try { + const result = await context.ui.summarising((report) => + runSummary( + context, + { + recordings, + template, + budget, + ...(options.lang === undefined ? {} : { language: options.lang }), + ...(options.context === undefined ? {} : { context: options.context }), + ...(options.fresh === true ? { fresh: true } : {}), + ...(options.save === false ? { save: false } : {}), }, - onFiles: async (files) => { - for (const file of files) { - await context.fs.writeTextFile(join(runDir.path, file.name), file.content); - } + { + onProgress: (stage, done, total) => { + report(stage, done, total); + job?.reporter.report({ stage, fraction: done / total }); + }, + onPlan: ({ reused, portions }) => { + if (reused > 0) { + context.ui.note( + `Reusing ${reused} stored ${reused === 1 ? 'summary' : 'summaries'} ` + + 'instead of transcripts (--fresh to read the transcripts again).', + ); + } + if (portions > 1) { + context.ui.note( + `Too long for ${summarizerName} in one pass: ${portions} portions, then combined.`, + ); + } + }, + onFiles: async (files) => { + for (const file of files) { + await context.fs.writeTextFile(join(runDir.path, file.name), file.content); + } + }, }, - }, - ), - ); + ), + ); - if (result.reportId !== null) context.ui.note(`Saved as ${result.reportId}.`); + if (result.reportId !== null) context.ui.note(`Saved as ${result.reportId}.`); - // A long report goes to the user's pager, where up, down and q - // already work the way they do in git and man. Only when there is a - // terminal to page on: shouldPage says no for a redirect or a pipe, - // which want the bytes and would hang waiting for a keypress nobody - // can give -- so "ailoud summarize ID > report.md" still writes a file. - if (shouldPage(result.body, process.stdout.isTTY === true)) { - await page(result.body, (chunk) => context.ui.content(chunk)); - return; + // A long report goes to the user's pager, where up, down and q + // already work the way they do in git and man. Only when there is a + // terminal to page on: shouldPage says no for a redirect or a pipe, + // which want the bytes and would hang waiting for a keypress nobody + // can give -- so "ailoud summarize ID > report.md" still writes a file. + if (shouldPage(result.body, process.stdout.isTTY === true)) { + await page(result.body, (chunk) => context.ui.content(chunk)); + } else { + context.ui.content(result.body); + } + // Never the summary body -- see JobState.result's own comment. + return { + reportId: result.reportId, + template: template.name, + portions: result.portions, + reused: result.reused, + provider: result.provider, + model: result.model, + }; + } finally { + await runDir.remove(); } - context.ui.content(result.body); - } finally { - await runDir.remove(); - } - }); + }); + }; + + if (job === undefined) { + await body(); + return; + } + // The try/catch wraps withJobLock itself, not just its body -- see + // transcribe.ts's identical comment. Taking the lock can throw before + // body() ever runs, and a catch placed inside withJobLock's callback + // never sees that. + try { + await withJobLock(context.paths.dataDir, async () => { + const result = await body(); + await job.reporter.finish(result); + }); + } catch (error) { + await job.reporter.fail(error instanceof Error ? error.message : String(error)); + throw error; + } }); } diff --git a/apps/cli/src/commands/template.ts b/apps/cli/src/commands/template.ts index 4af8395..a9c97d8 100644 --- a/apps/cli/src/commands/template.ts +++ b/apps/cli/src/commands/template.ts @@ -116,8 +116,8 @@ export function registerTemplate(parent: Command, context: CliContext): void { summary: options.summary ?? base?.summary ?? safe, }), ); - context.write(`Wrote ${path}`); - context.write(`Use it with: ailoud audio summarize --template ${safe}`); + context.ui.success(`Wrote ${path}`); + context.ui.note(`Use it with: ailoud audio summarize --template ${safe}`); }); }); } diff --git a/apps/cli/src/commands/testContext.ts b/apps/cli/src/commands/testContext.ts index 477a762..5933289 100644 --- a/apps/cli/src/commands/testContext.ts +++ b/apps/cli/src/commands/testContext.ts @@ -1,4 +1,16 @@ -import type { Diarizer, SpeechSegmenter, Summarizer, TranscriptionProvider } from '@ailoud/core'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DEFAULT_MAX_CPU_PERCENT, resourceBudget } from '@ailoud/core'; +import type { + Diarizer, + PublishedVersion, + ResourceBudget, + SpeechSegmenter, + Summarizer, + TranscriptionProvider, + VersionSource, +} from '@ailoud/core'; import { parseConfig } from '../config.js'; import { FakeAudioTool, @@ -29,12 +41,29 @@ export function context(): CliContext & { segmenterInstances: FakeSegmenter[]; diarizerInstances: FakeDiarizer[]; summarizerPrompts: string[]; + /** + * Every budget each factory was called with, in call order -- `undefined` + * where a caller passed none. Kept as four separate arrays, one per + * factory, rather than one shared array: a shared array is how a dropped + * argument at one call site went undetected, since `toContainEqual` + * against it passes as long as *any* factory received a matching budget, + * and `createStt` always does. A per-factory array can only be satisfied by + * that factory's own call sites. + */ + sttBudgets: Array; + segmenterBudgets: Array; + diarizerBudgets: Array; + summarizerBudgets: Array; } { const lines: string[] = []; const sttInstances: FakeStt[] = []; const segmenterInstances: FakeSegmenter[] = []; const diarizerInstances: FakeDiarizer[] = []; const summarizerPrompts: string[] = []; + const sttBudgets: Array = []; + const segmenterBudgets: Array = []; + const diarizerBudgets: Array = []; + const summarizerBudgets: Array = []; const write = (line: string): void => { lines.push(line); }; @@ -44,12 +73,19 @@ export function context(): CliContext & { segmenterInstances, diarizerInstances, summarizerPrompts, + sttBudgets, + segmenterBudgets, + diarizerBudgets, + summarizerBudgets, paths: { - configFile: '/c', + configFile: '/c/ailoud/config.yaml', + configHome: '/c', dataDir: '/d', dbFile: '/d/ailoud.db', mediaRoot: '/d/media', + jobsDir: '/d/jobs', isProjectLibrary: false, + userDataDir: '/d', }, config: { stt: { @@ -67,6 +103,9 @@ export function context(): CliContext & { }, }, llm: parseConfig(null).llm, + resources: parseConfig(null).resources, + audio: parseConfig(null).audio, + update: parseConfig(null).update, }, store: new InMemoryStore(), fs: new MemFs({ [FIXTURE_PATH]: 'AUDIO' }), @@ -78,7 +117,19 @@ export function context(): CliContext & { // output through `lines` and asserts on exact strings, the same // property the end-to-end suite leans on when it runs through a pipe. ui: new PlainUi(write), - createStt: (): TranscriptionProvider => { + // A fixed hybrid-cpu topology, not the real machine's: command tests in + // this package never assert on thread counts, and a value that changed + // with whatever ran the suite would be a fake worth distrusting. + resources: async (overrides = {}): Promise => + resourceBudget( + { logical: 10, performance: 8 }, + { + maxCpuPercent: overrides.maxCpuPercent ?? DEFAULT_MAX_CPU_PERCENT, + gpu: overrides.gpu ?? true, + }, + ), + createStt: (budget?: ResourceBudget): TranscriptionProvider => { + sttBudgets.push(budget); const stt = new FakeStt({ language: 'ru', model: 'base.bin', @@ -87,17 +138,20 @@ export function context(): CliContext & { sttInstances.push(stt); return stt; }, - createSegmenter: (): SpeechSegmenter => { + createSegmenter: (budget?: ResourceBudget): SpeechSegmenter => { + segmenterBudgets.push(budget); const segmenter = new FakeSegmenter([{ startMs: 0, endMs: 1500 }]); segmenterInstances.push(segmenter); return segmenter; }, - createDiarizer: (): Diarizer => { + createDiarizer: (budget?: ResourceBudget): Diarizer => { + diarizerBudgets.push(budget); const diarizer = new FakeDiarizer([{ startMs: 0, endMs: 1500, speaker: 'speaker_00' }]); diarizerInstances.push(diarizer); return diarizer; }, - createSummarizer: (): Summarizer => { + createSummarizer: (budget?: ResourceBudget): Summarizer => { + summarizerBudgets.push(budget); // Echoes back what it was asked, so a test can assert on the prompt the // pipeline built without needing a model. Specs that care about the // summary itself override this. @@ -112,6 +166,15 @@ export function context(): CliContext & { }; return summarizer; }, + // Reports no update by default -- a fixed list, never the network. + // Specs that care about `self check` override this field directly. + versionSource: { + published: async (): Promise => [ + { version: '1.0.0', deprecated: false }, + ], + } satisfies VersionSource, + updateRegistryHost: 'registry.npmjs.org', + updateTimeoutMs: 10_000, }; } @@ -141,14 +204,26 @@ export interface ContextWithTranscriptOptions { * actually do. `skipImport` yields an empty library; `skipTranscribe` * yields a recording with no transcript. */ -export async function contextWithTranscript( - opts: ContextWithTranscriptOptions = {}, -): Promise { +export async function contextWithTranscript(opts: ContextWithTranscriptOptions = {}): Promise< + CliContext & { + lines: string[]; + sttInstances: FakeStt[]; + summarizerPrompts: string[]; + sttBudgets: Array; + segmenterBudgets: Array; + diarizerBudgets: Array; + summarizerBudgets: Array; + } +> { const ctx = context(); const done = (): CliContext & { lines: string[]; sttInstances: FakeStt[]; summarizerPrompts: string[]; + sttBudgets: Array; + segmenterBudgets: Array; + diarizerBudgets: Array; + summarizerBudgets: Array; } => { if (opts.clearLines === true) ctx.lines.length = 0; return ctx; @@ -159,3 +234,26 @@ export async function contextWithTranscript( await buildProgram(ctx).parseAsync(['node', 'ailoud', 'transcribe']); return done(); } + +/** + * Points `context.paths.dataDir` at a real, writable temporary directory for + * the duration of `body`, then removes it. + * + * `withJobLock` (and the exclusive lock underneath it) takes its lock + * through node:fs directly rather than through the injected `Fs` port -- see + * `exclusiveLock.ts`'s own module comment for why -- so a `--job` test that + * exercises the lock needs a real directory underneath it, not the + * in-memory one every other command test runs against. + */ +export async function withRealDataDir(ctx: CliContext, body: () => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-cli-test-')); + // `paths` is declared readonly on CliContext so ordinary commands cannot + // repoint it mid-run; Object.assign does not go through that check, and + // this helper's entire job is to override it for one test. + Object.assign(ctx, { paths: { ...ctx.paths, dataDir: dir } }); + try { + return await body(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/apps/cli/src/commands/transcribe.ts b/apps/cli/src/commands/transcribe.ts index baab1db..3987e6f 100644 --- a/apps/cli/src/commands/transcribe.ts +++ b/apps/cli/src/commands/transcribe.ts @@ -1,8 +1,24 @@ import type { Command } from 'commander'; -import { summarizeLanguages, transcribeRecording, UsageError } from '@ailoud/core'; +import { Option } from 'commander'; +import { + FailureError, + summarizeLanguages, + transcribeRecording, + UsageError, + weightedOverall, +} from '@ailoud/core'; +import type { Recording } from '@ailoud/core'; import type { CliContext } from '../wiring.js'; import { resolveRecordings } from '../resolveId.js'; import { collectTag, parseTags } from '../tags.js'; +import { parseDenoise, parseMaxCpu } from './resourceOptions.js'; +import { JobLog } from '../jobs/log.js'; +import { JobReporter } from '../jobs/reporter.js'; +import { createJob } from '../jobs/store.js'; +import { jobBusyMessage, jobLockHolder, withJobLock } from '../jobs/lock.js'; +import { loadJob } from '../jobs/loadJob.js'; +import { jobStatePath } from '../jobs/state.js'; +import { spawnDetachedJob } from '../jobs/spawn.js'; interface TranscribeOptions { readonly lang?: string; @@ -12,6 +28,11 @@ interface TranscribeOptions { readonly diarize?: boolean; readonly speakers?: string; readonly tag?: string[]; + readonly maxCpu?: string; + readonly gpu?: boolean; + readonly denoise?: string; + readonly job?: string; + readonly detach?: boolean; } /** @@ -67,6 +88,109 @@ function parseSpeakerCount(raw: string): number { return value; } +interface ResolvedTranscribeRun { + readonly multilingual: boolean; + readonly languages: readonly string[]; + /** + * What the caller literally declared with `--lang`, for the job's + * `declared` field -- distinct from `languages` above, which is what the + * pipeline actually acts on. + * + * `--lang` absent and `--lang auto` both parse to the same empty + * `languages` set ("decide for yourself"), but they are not the same + * declaration: one caller said nothing, the other said "I don't know". + * Collapsing them would erase the one distinction `declared` exists to + * keep -- see JobState.declared's own comment ("a later question about + * why diarization went badly has an answer"). `["auto"]` here is that + * answer; `[]` means the flag was never given at all. + */ + readonly declaredLanguages: readonly string[]; + readonly speakers: number | undefined; + readonly tags: readonly string[]; + readonly recordings: readonly Recording[]; +} + +/** + * Validates and resolves everything transcribe needs before any work starts: + * the language set, the speaker count, the tags, and which recordings are in + * scope. + * + * Shared between the normal run and `--detach`, so a bad `--lang`, a bad + * `--speakers`, an unparseable tag, or an unresolvable recording id costs a + * usage error right here, at the prompt -- never an hour later in a job + * nobody is watching. + */ +async function resolveTranscribeRun( + context: CliContext, + ids: readonly string[], + options: TranscribeOptions, +): Promise { + if (options.force === true && ids.length === 0) { + throw new UsageError( + '--force needs explicit recording ids: it would otherwise re-transcribe the whole library.', + ); + } + const languages = parseLanguages(options.lang); + // See ResolvedTranscribeRun.declaredLanguages: parseLanguages collapses + // an absent --lang and an explicit "--lang auto" to the same empty array, + // which is right for the pipeline (both mean "decide for yourself") and + // wrong for the job record (only one of them is a caller saying "unknown" + // rather than "nothing declared"). + const declaredLanguages = + options.lang === undefined ? [] : languages.length === 0 ? ['auto'] : languages; + // Two or more languages IS the statement that the recording switches + // between them, so requiring --multilingual as well would be asking the + // user to say the same thing twice. + const multilingual = options.multilingual === true || languages.length >= 2; + if (options.speakers !== undefined && options.diarize !== true) { + // A flag that silently does nothing is worse than one that complains: + // without --diarize, --speakers has nothing to inform. + throw new UsageError('--speakers needs --diarize: it has no effect without it.'); + } + const speakers = options.speakers === undefined ? undefined : parseSpeakerCount(options.speakers); + // Parsed before any transcription starts: a bad tag should cost a usage + // error, not an hour of whisper followed by one. + const tags = parseTags(options.tag ?? []); + // Given ids, each may be a prefix; resolveRecordings refuses the whole set + // unless every one picks out exactly one recording. Given none, the + // default selector still means "everything not yet transcribed". + const recordings = + ids.length > 0 + ? await resolveRecordings(context.store, ids) + : await context.store.listRecordings({ withoutTranscript: true }); + return { multilingual, languages, declaredLanguages, speakers, tags, recordings }; +} + +/** + * Serializes the detached child's argv from the already-parsed, already- + * validated `ids` and `options` -- never from `process.argv`. + * + * An earlier version of this filtered `process.argv.slice(2)` for the + * literal string `'--detach'`. That breaks two ways: it silently drops any + * OTHER argument that happens to equal `"--detach"` too -- `--tag --detach` + * loses its tag value, not just the flag, since commander hands option + * values through untouched and never rejects one that looks like a flag + * name; and it trusts `process.argv` to still be exactly `[node, script, + * ...args]`, which is true for `node dist/bin/ailoud.js ...` but is not a + * promise any wrapper has to keep -- "pnpm ailoud ..." is how this project + * runs the CLI in development. Rebuilding from the typed values commander + * already parsed sidesteps both: nothing here ever inspects raw argv. + */ +function transcribeChildArgs(ids: readonly string[], options: TranscribeOptions): string[] { + const args: string[] = ['transcribe', ...ids]; + if (options.lang !== undefined) args.push('--lang', options.lang); + if (options.model !== undefined) args.push('--model', options.model); + if (options.force === true) args.push('--force'); + if (options.multilingual === true) args.push('--multilingual'); + if (options.diarize === true) args.push('--diarize'); + if (options.speakers !== undefined) args.push('--speakers', options.speakers); + for (const tag of options.tag ?? []) args.push('--tag', tag); + if (options.maxCpu !== undefined) args.push('--max-cpu', options.maxCpu); + if (options.gpu === false) args.push('--no-gpu'); + if (options.denoise !== undefined) args.push('--denoise', options.denoise); + return args; +} + export function registerTranscribe(program: Command, context: CliContext): void { program .command('transcribe') @@ -87,96 +211,264 @@ export function registerTranscribe(program: Command, context: CliContext): void ) .option('--diarize', 'attribute segments to speakers by running speaker diarization') .option('--speakers ', 'known number of speakers, to help the diarizer') + .option( + '--max-cpu ', + 'share of this machine to use, 1 to 100 (default: the configured 90)', + ) + .option('--no-gpu', 'do not use the GPU, even where a binary supports it') + .option('--denoise ', 'auto, on or off (default: the configured off)') .option('--tag ', 'group these recordings under a tag; repeatable', collectTag) + // Hidden, and not a feature: this is how the detached child started by + // `--detach` and by the MCP server is told which job it is. A user has + // no reason to pass it, and `--help` listing it would invite exactly the + // hand-made half-registered job this avoids. + .addOption( + new Option( + '--job ', + 'report into an existing job state file instead of the terminal', + ).hideHelp(), + ) + .option('--detach', 'start the work in the background and print its job id') .description('Turn recordings into transcripts') .action(async (ids: string[], options: TranscribeOptions) => { - await context.ui.frame('Transcribing', async () => { - if (options.force === true && ids.length === 0) { + // The two ends of one mechanism: --job is how a detached child reports + // in, --detach is how one gets started. Naming both says two + // contradictory things about who is driving this run. + if (options.detach === true && options.job !== undefined) { + throw new UsageError('--detach cannot be combined with --job.'); + } + + // Parsed above the --detach branch, before a job file exists or + // anything is spawned, so a bad --max-cpu or --denoise costs nothing -- + // the detached path validates here too, even though the budget and + // denoise mode computed below are only used by the run that happens in + // this process, never by the detached child (which parses its own argv + // and computes its own). + const budget = await context.resources({ + ...(options.maxCpu === undefined ? {} : { maxCpuPercent: parseMaxCpu(options.maxCpu) }), + ...(options.gpu === false ? { gpu: false } : {}), + }); + const denoise = + options.denoise === undefined + ? context.config.audio.denoise + : parseDenoise(options.denoise); + + if (options.detach === true) { + // Every validation the normal run would do, run here, before the job + // file exists or anything is spawned -- see resolveTranscribeRun's + // own comment. + const resolved = await resolveTranscribeRun(context, ids, options); + if (resolved.recordings.length === 0) { + // Only reachable via the default selector: with explicit ids, an + // empty result means every id was missing, and resolveRecordings + // (inside resolveTranscribeRun) already threw for that. Refused + // here, before the lock check or createJob -- the same point + // summarize refuses an empty selection -- because handing back a + // job id for "there was never anything to do" is a confusing + // artifact, and taking the job lock for it is pointless. throw new UsageError( - '--force needs explicit recording ids: it would otherwise re-transcribe the whole library.', + '--detach has nothing to transcribe: every recording already has a transcript. ' + + 'Pass ids explicitly, or --force, to redo one.', ); } - const languages = parseLanguages(options.lang); - // Two or more languages IS the statement that the recording switches - // between them, so requiring --multilingual as well would be asking - // the user to say the same thing twice. - const multilingual = options.multilingual === true || languages.length >= 2; - if (options.speakers !== undefined && options.diarize !== true) { - // A flag that silently does nothing is worse than one that - // complains: without --diarize, --speakers has nothing to inform. - throw new UsageError('--speakers needs --diarize: it has no effect without it.'); + const holder = await jobLockHolder(context.paths.dataDir); + if (holder !== null) { + throw new FailureError(jobBusyMessage(holder)); } - const speakers = - options.speakers === undefined ? undefined : parseSpeakerCount(options.speakers); - // Parsed before any transcription starts: a bad tag should cost a - // usage error, not an hour of whisper followed by one. - const tags = parseTags(options.tag ?? []); - // Given ids, each may be a prefix; resolveRecordings refuses the whole - // set unless every one picks out exactly one recording. Given none, - // the default selector still means "everything not yet transcribed". - const recordings = - ids.length > 0 - ? await resolveRecordings(context.store, ids) - : await context.store.listRecordings({ withoutTranscript: true }); - - if (recordings.length === 0) { - // Only reachable via the default selector: with explicit ids, an - // empty result means every id was missing, and that already threw - // above. - context.ui.nothingToTranscribe(); - return; + const job = await createJob( + { + fs: context.fs, + ids: context.ids, + clock: context.clock, + jobsDir: context.paths.jobsDir, + }, + { + kind: 'transcribe', + recordings: resolved.recordings.length, + declared: { + speakers: resolved.speakers ?? 'unknown', + languages: resolved.declaredLanguages, + }, + }, + ); + try { + await spawnDetachedJob( + { fs: context.fs, jobsDir: context.paths.jobsDir }, + transcribeChildArgs(ids, options), + job, + ); + } catch (error) { + // The id below must always resolve: if the child never started, + // the job file must say so rather than "running" forever. + const reporter = new JobReporter({ + fs: context.fs, + jobsDir: context.paths.jobsDir, + initial: job, + log: new JobLog(job.log), + }); + await reporter.fail(error instanceof Error ? error.message : String(error)); + throw error; } + context.ui.success( + `started job ${job.id} -- progress in ${jobStatePath(context.paths.jobsDir, job.id)}`, + ); + return; + } - const stt = context.createStt(); - const segmenter = multilingual ? context.createSegmenter() : undefined; - const diarizer = options.diarize === true ? context.createDiarizer() : undefined; - for (const recording of recordings) { - if (options.force !== true) { - const existing = await context.store.latestTranscript(recording.id); - if (existing !== null) { - context.ui.skipped(recording); - continue; - } + const job = await loadJob(context, options.job); + + const body = async (): Promise => + context.ui.frame('Transcribing', async () => { + const { multilingual, languages, speakers, tags, recordings } = + await resolveTranscribeRun(context, ids, options); + + if (recordings.length === 0) { + // Only reachable via the default selector: with explicit ids, an + // empty result means every id was missing, and that already threw + // above. + context.ui.nothingToTranscribe(); + return { transcribed: [] }; } - const transcript = await context.ui.transcribing(recording, () => - transcribeRecording( - { - fs: context.fs, - store: context.store, - audio: context.audio, - stt, - clock: context.clock, - ids: context.ids, - mediaRoot: context.paths.mediaRoot, - onWarning: (message) => context.ui.warn(message), - ...(segmenter === undefined ? {} : { segmenter }), - ...(diarizer === undefined ? {} : { diarizer }), - }, + + const stt = context.createStt(budget); + const segmenter = multilingual ? context.createSegmenter(budget) : undefined; + const diarizer = options.diarize === true ? context.createDiarizer(budget) : undefined; + const transcribed: Array<{ + recordingId: string; + transcriptId: string; + language: string; + segments: number; + /** Diarizer labels this run produced; empty without --diarize. */ + speakers: string[]; + }> = []; + // Collected here, not just handed to context.ui.warn: there is no + // terminal to read under a detached job (stdio is 'ignore'), and a + // diarizer that failed silently would leave a poller of job_status + // believing the transcript has speakers when it does not. Folded + // into the returned result below so it reaches reporter.finish(), + // and from there whoever polls the job. + const warnings: string[] = []; + // Reused whenever a stage's fraction cannot be measured (the + // diarizer pass), so that stage only changes the text shown to the + // user and never walks the number backwards. The same rule + // weightedOverall and JobReporter.report already follow. + let lastFraction = 0; + for (const [index, recording] of recordings.entries()) { + if (options.force !== true) { + const existing = await context.store.latestTranscript(recording.id); + if (existing !== null) { + context.ui.skipped(recording); + continue; + } + } + const transcript = await context.ui.transcribing(recording, (report) => + transcribeRecording( + { + fs: context.fs, + store: context.store, + audio: context.audio, + stt, + clock: context.clock, + ids: context.ids, + mediaRoot: context.paths.mediaRoot, + onWarning: (message) => { + context.ui.warn(message); + job?.log.append(`warning: ${message}`); + warnings.push(message); + }, + // The job log only, never ui.warn -- which onWarning above + // already reaches. A foreground run has no job log and so + // correctly prints nothing here. + onNotice: (message) => { + job?.log.append(message); + }, + onProgress: (event) => { + // Weighted by duration across the batch, so finishing four + // short recordings out of five does not claim 80%. + const overall = + event.fraction === undefined + ? lastFraction + : weightedOverall( + recordings.map((r) => r.durationMs), + index, + event.fraction, + ); + lastFraction = overall; + report(event.stage, overall); + job?.reporter.report({ stage: event.stage, fraction: overall }); + }, + ...(segmenter === undefined ? {} : { segmenter }), + ...(diarizer === undefined ? {} : { diarizer }), + }, + recording, + { + // One declared language means force it for the whole file, the + // single-pass case. Two or more means multilingual, where the + // set constrains detection instead of forcing an answer. The + // single-language-plus---multilingual case lands in the second + // branch with a one-member set: degenerate, but coherent, and + // not worth refusing. + ...(!multilingual && languages.length === 1 ? { language: languages[0] } : {}), + ...(options.model === undefined ? {} : { model: options.model }), + ...(multilingual ? { multilingual: true, declaredLanguages: languages } : {}), + ...(options.diarize === true ? { diarize: true } : {}), + ...(speakers === undefined ? {} : { speakers }), + denoise, + }, + ), + ); + job?.reporter.advance(index + 1); + if (tags.length > 0) await context.store.addTags(recording.id, tags); + const segments = await context.store.listSegments(transcript.id); + context.ui.transcribed( recording, - { - // One declared language means force it for the whole file, the - // single-pass case. Two or more means multilingual, where the - // set constrains detection instead of forcing an answer. The - // single-language-plus---multilingual case lands in the second - // branch with a one-member set: degenerate, but coherent, and - // not worth refusing. - ...(!multilingual && languages.length === 1 ? { language: languages[0] } : {}), - ...(options.model === undefined ? {} : { model: options.model }), - ...(multilingual ? { multilingual: true, declaredLanguages: languages } : {}), - ...(options.diarize === true ? { diarize: true } : {}), - ...(speakers === undefined ? {} : { speakers }), - }, - ), - ); - if (tags.length > 0) await context.store.addTags(recording.id, tags); - const segments = await context.store.listSegments(transcript.id); - context.ui.transcribed( - recording, - transcript, - segments.length, - summarizeLanguages(segments), - ); - } - }); + transcript, + segments.length, + summarizeLanguages(segments), + ); + transcribed.push({ + recordingId: recording.id, + transcriptId: transcript.id, + language: transcript.language, + segments: segments.length, + // Recorded here because the segments are already in memory. + // `job_status` needs to know whether this run produced speaker + // labels, and reading them back would mean loading every + // segment of an hour-long transcript on a call whose whole + // point is being cheap enough to poll. + speakers: [ + ...new Set( + segments + .map((segment) => segment.speaker) + .filter((label): label is string => label !== null), + ), + ].sort(), + }); + } + return { transcribed, ...(warnings.length === 0 ? {} : { warnings }) }; + }); + + if (job === undefined) { + await body(); + return; + } + // The try/catch wraps withJobLock itself, not just its body. Taking + // the lock can throw before body() ever runs -- losing the advisory + // race against another process, or any other failure on the way in -- + // and a catch placed inside withJobLock's callback never sees that: + // the state file would stay 'running' forever with nothing to explain + // why, while withLiveness eventually reports it failed pointing at a + // log that was never created. See the spec, section 2: "the child then + // fails cleanly against the real lock with the same message the parent + // would have given" -- which only happens if something records it. + try { + await withJobLock(context.paths.dataDir, async () => { + const result = await body(); + await job.reporter.finish(result); + }); + } catch (error) { + await job.reporter.fail(error instanceof Error ? error.message : String(error)); + throw error; + } }); } diff --git a/apps/cli/src/completions/generate.test.ts b/apps/cli/src/completions/generate.test.ts new file mode 100644 index 0000000..861d5a7 --- /dev/null +++ b/apps/cli/src/completions/generate.test.ts @@ -0,0 +1,219 @@ +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; +import { describeTree, renderCompletions } from './generate.js'; + +function sample(): Command { + const program = new Command().name('ailoud').description('root'); + const audio = program.command('audio').alias('recordings').description('recordings'); + audio + .command('ls') + .alias('l') + .description('list them') + .option('--json', 'as JSON') + // Short-only, so "collects long options" can fail: with `option.flags` + // instead of `option.long` this would land in the script as `-q, --quiet` + // -- except there is no long form, so it must not land at all. + .option('-q', 'quietly'); + // The hidden top-level alias `inGroupAndTopLevel` adds for every verb. + const hidden = new Command('ls').description('list them'); + program.addCommand(hidden, { hidden: true }); + return program; +} + +describe('describeTree', () => { + it('keeps multi-letter aliases and drops one-letter ones', () => { + // A completion list is for discovery; a one-letter alias is for someone + // who already knows it and will not press Tab. Listing both spellings of + // every verb doubles the list to serve nobody. + const tree = describeTree(sample()); + const audio = tree.children.find((c) => c.name === 'audio')!; + expect(audio.aliases).toEqual(['recordings']); + expect(audio.children.find((c) => c.name === 'ls')!.aliases).toEqual([]); + }); + + it('includes the hidden top-level alias, because it really works', () => { + // Hidden from --help to keep it readable, but `ailoud ls` is a real + // invocation. A Tab list that omits it would be wrong about the tool. + const tree = describeTree(sample()); + expect(tree.children.map((c) => c.name)).toContain('ls'); + }); + + it('collects long options and ignores short ones', () => { + const tree = describeTree(sample()); + const ls = tree.children.find((c) => c.name === 'audio')!.children[0]!; + expect(ls.options).toContain('--json'); + // `-q` has no long form at all, so nothing about it may reach the script. + expect(ls.options.every((o) => o.startsWith('--'))).toBe(true); + expect(ls.options).not.toContain('-q'); + expect(ls.options.join(' ')).not.toContain('quiet'); + }); + + it('adds --help and --version to every command, not just the root', () => { + // Commander answers both at every depth but registers neither in + // `Command.options` below the root, so without this they appear nowhere: + // `ailoud audio ls --he` completed nothing in real bash. + const tree = describeTree(sample()); + const ls = tree.children.find((c) => c.name === 'audio')!.children[0]!; + expect(ls.options).toContain('--help'); + expect(ls.options).toContain('--version'); + }); + + it('does not repeat --version at the root, where commander registers it', () => { + // A duplicate would show twice in the Tab list, and reordering or + // re-adding on each run would make `update` report a change every time. + const program = new Command().name('ailoud').version('1.0.0'); + const options = describeTree(program).options; + expect(options.filter((o) => o === '--version')).toHaveLength(1); + }); +}); + +describe('renderCompletions', () => { + it('emits the shape bash needs and names the real commands', () => { + const script = renderCompletions('bash', describeTree(sample())); + expect(script).toContain('-F _ailoud ailoud'); + expect(script).toContain('audio'); + expect(script).toContain('recordings'); + expect(script).toContain('--json'); + }); + + it('emits the shape zsh needs, with #compdef on the first line', () => { + // zsh only treats a file in fpath as a completion when its first line + // is the #compdef tag; anywhere else it is an ordinary comment. + const script = renderCompletions('zsh', describeTree(sample())); + expect(script.split('\n')[0]).toBe('#compdef ailoud'); + expect(script).toContain('audio'); + }); + + it('never declares a local named "path" in zsh, which is tied to $PATH', () => { + // zsh links the array `path` to the scalar `PATH`. `local path=""` empties + // $PATH for the whole completion call -- verified in zsh 5.9, where the + // function saw `PATH=audio import` while completing `ailoud audio import`. + // Every external command run from the function, and every autoloaded + // helper that runs one, then fails with "command not found" and Tab + // silently returns nothing. + const script = renderCompletions('zsh', describeTree(sample())); + expect(script).not.toMatch(/\blocal\b[^\n]*\bpath=/); + expect(script).not.toMatch(/\$\{?path\b/); + }); + + it('emits the shape fish needs, with descriptions', () => { + const script = renderCompletions('fish', describeTree(sample())); + expect(script).toContain('complete -c ailoud'); + expect(script).toContain('recordings'); + expect(script).toContain('list them'); + }); + + it('escapes an apostrophe in the fish description, the only shell that emits one', () => { + // A description with an apostrophe closed the quoting and produced a + // script that fails to parse -- silently, because nothing sources it + // until the user opens a new terminal. + // + // fish only: bash and zsh emit no descriptions at all, so asserting the + // raw string is absent from those two passed for the wrong reason. What + // matters is not that the raw form is missing but that the escaped form + // is present, since an implementation that dropped the description + // entirely would also satisfy "does not contain". + const program = new Command().name('ailoud'); + program.command('x').description("don't break"); + const fish = renderCompletions('fish', describeTree(program)); + expect(fish).toContain("-d 'don'\\''t break'"); + expect(fish).not.toContain("-d 'don't break'"); + }); + + it('leaves filename completion working in every shell', () => { + // Installing completions REMOVED filename completion. Verified in bash + // 3.2: before installing, `ailoud audio import fx/` listed the media + // files; after, it rang the bell twice and offered nothing. `complete -F` + // alone tells bash the function is the whole answer, fish's `-f` says the + // command takes no file at all, and the zsh function never reached + // `_files`. `import` and `transcribe` are the commands users type most. + const tree = describeTree(sample()); + expect(renderCompletions('bash', tree)).toContain('complete -o default -o bashdefault -F'); + expect(renderCompletions('zsh', tree)).toContain('_files'); + expect(renderCompletions('fish', tree)).not.toContain('complete -c ailoud -f\n'); + }); + + it('offers options only once the word starts with a dash', () => { + // One merged candidate list meant `ailoud audio import ` answered + // with the option names, so the fallback to files above was never + // reached for the empty word -- which is how that argument is usually + // typed. + const bash = renderCompletions('bash', describeTree(sample())); + expect(bash).toContain('-*) COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) ;;'); + expect(bash).toContain('*) COMPREPLY=( $(compgen -W "$subs" -- "$cur") ) ;;'); + // A leaf: no subcommand of its own, so an empty word falls through to the + // filename completion `-o default` restores. + expect(bash).toContain('subs=""; opts="--json --help --version"'); + expect(bash).toContain('subs="audio recordings ls"; opts="--help --version"'); + + const zsh = renderCompletions('zsh', describeTree(sample())); + expect(zsh).toContain('if [[ $cur == -* ]]; then'); + expect(zsh).toContain('compadd -- ${=subs} || _files'); + }); + + it('emits options for fish too, not subcommands only', () => { + // fish was the only shell that emitted no options at all: `ailoud audio + // ls --` offered nothing there while bash offered `--json --tag`. + // The design draws no distinction between the shells. + const script = renderCompletions('fish', describeTree(sample())); + const lsOptions = script + .split('\n') + .filter((line) => line.includes('__fish_seen_subcommand_from ls') && line.includes(' -l ')); + expect(lsOptions.some((line) => line.endsWith('-l json'))).toBe(true); + expect(lsOptions.some((line) => line.endsWith('-l help'))).toBe(true); + // `-l name`, never `-a '--name'`: only `-l` tells fish the word is a long + // option, which is what makes it complete after a bare `--`. + expect(script).not.toContain("-a '--json'"); + }); + + it('routes through an alias, not only past it', () => { + // `recordings` was offered as a candidate and then completed nothing: + // verified in real bash, where `ailoud recordings ` produced two + // bells and no list. Offering a word and then having nothing follow it is + // worse than never offering it. + const tree = describeTree(sample()); + expect(renderCompletions('bash', tree)).toContain('"recordings")'); + expect(renderCompletions('zsh', tree)).toContain('"recordings")'); + expect(renderCompletions('fish', tree)).toContain( + "__fish_seen_subcommand_from recordings' -a 'ls'", + ); + }); + + it('produces the same bytes for the same tree', () => { + // `update` compares before and after to report "unchanged"; a generator + // that reorders its own output would rewrite the file on every run. + const a = renderCompletions('bash', describeTree(sample())); + const b = renderCompletions('bash', describeTree(sample())); + expect(a).toBe(b); + }); + + it('gives fish one condition per ancestor, so two parents nesting the same child name stay distinct', () => { + // `self completions` and `other completions` both nest a subcommand + // named `completions`. A condition that named only the immediate parent + // would read `__fish_seen_subcommand_from completions` for both, so + // `ailoud other completions ` would offer `self completions`'s + // children (`install`, `uninstall`) alongside its own (`foo`). + const program = new Command().name('ailoud'); + const self = program.command('self').description('self management'); + const selfCompletions = self.command('completions').description('shell completions'); + selfCompletions.command('install').description('install them'); + selfCompletions.command('uninstall').description('remove them'); + const other = program.command('other').description('other things'); + const otherCompletions = other.command('completions').description('other completions'); + otherCompletions.command('foo').description('do foo'); + + const script = renderCompletions('fish', describeTree(program)); + const lines = script.split('\n'); + const conditionsFor = (name: string): string => { + const line = lines.find((l) => l.includes(`-a '${name}'`)); + expect(line).toBeDefined(); + return line!.slice(0, line!.indexOf(" -a '")); + }; + + const install = conditionsFor('install'); + const foo = conditionsFor('foo'); + expect(install).not.toBe(foo); + expect(install).toContain('__fish_seen_subcommand_from self'); + expect(foo).toContain('__fish_seen_subcommand_from other'); + }); +}); diff --git a/apps/cli/src/completions/generate.ts b/apps/cli/src/completions/generate.ts new file mode 100644 index 0000000..9d8c831 --- /dev/null +++ b/apps/cli/src/completions/generate.ts @@ -0,0 +1,262 @@ +import type { Command } from 'commander'; + +export type Shell = 'bash' | 'zsh' | 'fish'; + +/** + * The shells ailoud can install completions for. + * + * `ash`, `dash` and a bare `sh` are absent deliberately: they have no + * programmable completion at all, so there is no script to install. Writing + * a file nothing reads looks exactly like a successful install, and the user + * only finds out when Tab does nothing. + */ +export const SHELLS: readonly Shell[] = ['bash', 'zsh', 'fish']; + +/** One command in the tree, reduced to what a completion script needs. */ +export interface CommandNode { + readonly name: string; + readonly description: string; + /** Multi-letter aliases only. */ + readonly aliases: readonly string[]; + /** Long option flags, e.g. `--json`. */ + readonly options: readonly string[]; + readonly children: readonly CommandNode[]; +} + +/** + * The two options commander answers on every command in the tree, at every + * depth, without registering either in `Command.options`. + * + * They therefore have to be added here or they appear nowhere: before this, + * `ailoud audio ls --he` completed nothing, and `--version` showed up + * only at the root, where commander does put it in `options`. Both really do + * work everywhere -- `ailoud audio ls --version` prints the version and + * `ailoud audio ls --help` prints that command's help -- so listing them is + * not a completion promising an invocation that fails. + */ +const GLOBAL_OPTIONS: readonly string[] = ['--help', '--version']; + +/** + * The command tree, reduced to what a completion script needs. + * + * Read off the live commander tree rather than a list kept beside it. A + * second statement of what the commands are goes stale: this project's own + * AGENTS.md records a command table that said `search` and `summarize` "do + * not exist yet" long after both shipped. + * + * One-letter aliases are dropped -- see the test for why. Hidden commands + * are kept: `inGroupAndTopLevel` hides the top-level spelling of every verb + * from `--help` to keep that readable, but `ailoud ls` is a real invocation + * and a Tab list that omitted it would be wrong about what works. + */ +export function describeTree(command: Command): CommandNode { + // `option.long`, not `option.flags`: `flags` is the whole registration + // string, so `-t, --tag ` would go into the script verbatim and offer + // a candidate no shell can complete to. + const longs = command.options + .map((option) => option.long) + .filter((long): long is string => typeof long === 'string'); + return { + name: command.name(), + description: command.description(), + aliases: command.aliases().filter((alias) => alias.length > 1), + // Appended, and only when missing, so the root -- where commander does + // register `--version` -- keeps one copy and the order stays stable + // across runs, which is what lets `update` report "unchanged". + options: [...longs, ...GLOBAL_OPTIONS.filter((global) => !longs.includes(global))], + children: command.commands.map(describeTree), + }; +} + +/** + * One command path, with the words that complete it. + * + * Subcommands and options are kept apart rather than merged into one list + * because the shells offer them at different moments: a word starting with + * `-` is being completed as an option, anything else as a subcommand. Merging + * them is what made `ailoud audio import ` answer with five option names + * where the user wanted a file. + */ +interface PathEntry { + readonly path: readonly string[]; + /** Subcommand names, each spelling of them. */ + readonly subs: readonly string[]; + /** Long option flags, e.g. `--json`. */ + readonly opts: readonly string[]; + readonly node: CommandNode; +} + +/** + * Every command path, with the words that follow it. Depth-first, stable order. + * + * Recursed once per SPELLING of each child, not once per canonical name. The + * aliases are already offered as candidates -- the design lists the group + * plurals as "words a user types and might Tab" -- and descending only through + * canonical names meant `ailoud recordings ` matched no case arm and + * completed nothing in all three shells. Offering a word and then completing + * nothing after it is worse than never offering it. + * + * The duplication this costs is bounded by the aliases that survive + * `describeTree`: three group plurals, each on a leaf-bearing group. One-letter + * aliases are already dropped there, which is what keeps this from doubling + * every verb. + */ +function paths(node: CommandNode, prefix: readonly string[] = []): PathEntry[] { + const subs = node.children.flatMap((child) => [child.name, ...child.aliases]); + const here: PathEntry[] = [{ path: prefix, subs, opts: node.options, node }]; + return here.concat( + node.children.flatMap((child) => + [child.name, ...child.aliases].flatMap((spelling) => paths(child, [...prefix, spelling])), + ), + ); +} + +/** + * A description, safe to sit inside single quotes in any of the three shells. + * + * An apostrophe in a description closed the quoting and produced a script + * that fails to parse -- silently, because nothing sources it until the user + * opens a new terminal. + */ +function quote(text: string): string { + return text.replace(/'/g, "'\\''"); +} + +/** The `case` label for one command path: the empty path is the bare `ailoud`. */ +function caseLabel(path: readonly string[]): string { + return path.length === 0 ? '""' : `"${path.join(' ')}"`; +} + +function renderBash(tree: CommandNode): string { + const cases = paths(tree) + .map( + ({ path, subs, opts }) => + ` ${caseLabel(path)})\n subs="${subs.join(' ')}"; opts="${opts.join(' ')}" ;;`, + ) + .join('\n'); + return [ + '_ailoud() {', + ' local cur path i subs opts', + ' cur="${COMP_WORDS[COMP_CWORD]}"', + ' path=""', + ' for (( i=1; i < COMP_CWORD; i++ )); do', + ' case "${COMP_WORDS[i]}" in -*) continue ;; esac', + ' path="${path:+$path }${COMP_WORDS[i]}"', + ' done', + ' subs=""', + ' opts=""', + ' case "$path" in', + cases, + ' esac', + // Options only while the word being completed already starts with `-`. + // Merging them into one list meant `ailoud audio import ` answered + // with the option names and nothing else, where the argument the user is + // actually typing is a media file. + ' case "$cur" in', + ' -*) COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) ;;', + ' *) COMPREPLY=( $(compgen -W "$subs" -- "$cur") ) ;;', + ' esac', + '}', + // `-o default -o bashdefault`, without which installing completions REMOVED + // filename completion: `complete -F` alone tells bash the function is the + // whole answer, so an empty COMPREPLY means "no completions" rather than + // "fall back". Verified in bash 3.2 -- before installing, `ailoud audio + // import fx/` listed the media files; after, it rang the bell twice. + // `import` and `transcribe` are the commands users type most, so a + // completion feature that breaks paths for them is worse than none. + 'complete -o default -o bashdefault -F _ailoud ailoud', + '', + ].join('\n'); +} + +function renderZsh(tree: CommandNode): string { + const cases = paths(tree) + .map( + ({ path, subs, opts }) => + ` ${caseLabel(path)})\n subs="${subs.join(' ')}"; opts="${opts.join(' ')}" ;;`, + ) + .join('\n'); + return [ + '#compdef ailoud', + '_ailoud() {', + // `_path`, never `path`: zsh ties the array `path` to the scalar `PATH`, + // so `local path=""` empties `$PATH` for the whole call and it ends up + // holding the words being completed. Verified in zsh 5.9: during `ailoud + // audio import ` the function saw `PATH=audio import`, and `date` + // run from inside it failed with "command not found". `_files` below is + // exactly the kind of helper that would have died there. + ' local _path="" subs="" opts="" cur i', + ' for (( i = 2; i < CURRENT; i++ )); do', + ' [[ ${words[i]} == -* ]] && continue', + ' _path="${_path:+$_path }${words[i]}"', + ' done', + ' cur="${words[CURRENT]}"', + ' case "$_path" in', + cases, + ' esac', + // Same split as bash, and the same fallback: `_files` only once `compadd` + // has reported that it matched nothing, so `ailoud ` still lists the + // commands alone instead of every file in the directory. + ' if [[ $cur == -* ]]; then', + ' compadd -- ${=opts}', + ' else', + ' compadd -- ${=subs} || _files', + ' fi', + '}', + '_ailoud "$@"', + '', + ].join('\n'); +} + +function renderFish(tree: CommandNode): string { + // No `complete -c ailoud -f`. That line said "this command never takes a + // file", which is how fish's half of the same defect bash had appeared: + // `ailoud audio import ` stopped offering media files the moment the + // completions were installed. Without it fish keeps its own filename + // completion alongside ours, which is what the user had before installing. + const lines: string[] = []; + for (const { path, node } of paths(tree)) { + // Fish ANDs multiple `-n` flags, so a completion three levels deep + // needs one flag per ancestor segment, not just the last. Naming only + // the last segment let two unrelated parents that both nest a + // same-named child -- `self completions` and `other completions` -- + // share one condition, so `ailoud other completions ` offered + // `self completions`'s children too. + // + // The same condition covers this node's own options: reaching them means + // having typed the same segments that reaching its children does. + const flags = + path.length === 0 + ? "-n '__fish_use_subcommand'" + : path.map((segment) => `-n '__fish_seen_subcommand_from ${segment}'`).join(' '); + for (const child of node.children) { + for (const name of [child.name, ...child.aliases]) { + lines.push(`complete -c ailoud ${flags} -a '${name}' -d '${quote(child.description)}'`); + } + } + // Options too, which fish alone was missing: bash and zsh both put + // `node.options` in their candidate list, so `ailoud audio ls --` + // offered `--json --tag` there and nothing at all in fish. The design + // draws no distinction between the shells here. + // + // `-l ` rather than `-a '--name'`: it is how fish is told a word is + // a long option, which is what makes it complete after a bare `--` and + // keeps it out of the argument list. + for (const option of node.options) { + lines.push(`complete -c ailoud ${flags} -l ${option.replace(/^--/, '')}`); + } + } + lines.push(''); + return lines.join('\n'); +} + +export function renderCompletions(shell: Shell, tree: CommandNode): string { + switch (shell) { + case 'bash': + return renderBash(tree); + case 'zsh': + return renderZsh(tree); + case 'fish': + return renderFish(tree); + } +} diff --git a/apps/cli/src/completions/install.test.ts b/apps/cli/src/completions/install.test.ts new file mode 100644 index 0000000..958eaf9 --- /dev/null +++ b/apps/cli/src/completions/install.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { MemFs } from '@ailoud/core/testing'; +import type { CommandNode } from './generate.js'; +import { START, install, refresh, uninstall } from './install.js'; +import { findShell } from './shells.js'; + +const HOME = '/home/ann'; +const CONFIG = '/home/ann/.config'; +const DATA = '/home/ann/.local/share/ailoud'; +const PLACES = { home: HOME, configHome: CONFIG, userDataDir: DATA }; + +const TREE: CommandNode = { + name: 'ailoud', + description: 'root', + aliases: [], + options: ['--json'], + children: [{ name: 'ls', description: 'list', aliases: [], options: [], children: [] }], +}; + +const actions = (files: readonly { path: string; action: string }[]) => + Object.fromEntries(files.map((file) => [file.path, file.action])); + +describe('install', () => { + it('writes the bash script and adds the block to .bashrc, leaving a hand-written line alone', async () => { + const fs = new MemFs({ [`${HOME}/.bashrc`]: 'export PATH="$PATH:/opt/tool/bin"\n' }); + const bash = findShell('bash')!; + const outcome = await install(fs, bash, TREE, PLACES); + const byPath = actions(outcome.files); + expect(byPath[`${DATA}/completions/ailoud.bash`]).toBe('created'); + expect(byPath[`${HOME}/.bashrc`]).toBe('updated'); + const rc = await fs.readTextFile(`${HOME}/.bashrc`); + expect(rc).toContain('export PATH="$PATH:/opt/tool/bin"'); + expect(rc).toContain(START); + expect(await fs.readTextFile(`${DATA}/completions/ailoud.bash`)).toContain('_ailoud'); + }); + + it('reports unchanged for both files on a second run', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + await install(fs, bash, TREE, PLACES); + const second = await install(fs, bash, TREE, PLACES); + expect(Object.values(actions(second.files))).toEqual(['unchanged', 'unchanged']); + }); + + it('writes only the script for fish, with no rc outcome at all', async () => { + const fs = new MemFs({}); + const fish = findShell('fish')!; + const outcome = await install(fs, fish, TREE, PLACES); + expect(outcome.files).toHaveLength(1); + expect(outcome.files[0]!.path).toBe(`${CONFIG}/fish/completions/ailoud.fish`); + expect(outcome.files[0]!.action).toBe('created'); + }); + + it('returns a non-empty note when .bash_profile exists and never sources .bashrc', async () => { + const fs = new MemFs({ + [`${HOME}/.bash_profile`]: 'export PATH="$PATH:/opt/tool/bin"\n', + }); + const bash = findShell('bash')!; + const outcome = await install(fs, bash, TREE, PLACES); + expect(outcome.note).toContain('.bash_profile'); + }); + + it('returns an empty note when there is no .bash_profile to warn about', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + const outcome = await install(fs, bash, TREE, PLACES); + expect(outcome.note).toBe(''); + }); +}); + +describe('uninstall', () => { + it('removes the block and deletes the script, leaving a hand-written line untouched', async () => { + const fs = new MemFs({ [`${HOME}/.bashrc`]: 'export PATH="$PATH:/opt/tool/bin"\n' }); + const bash = findShell('bash')!; + await install(fs, bash, TREE, PLACES); + + const outcome = await uninstall(fs, bash, PLACES); + const byPath = actions(outcome.files); + expect(byPath[`${DATA}/completions/ailoud.bash`]).toBe('removed'); + expect(byPath[`${HOME}/.bashrc`]).toBe('cleaned'); + expect(await fs.exists(`${DATA}/completions/ailoud.bash`)).toBe(false); + const rc = await fs.readTextFile(`${HOME}/.bashrc`); + expect(rc).toContain('export PATH="$PATH:/opt/tool/bin"'); + expect(rc).not.toContain(START); + }); + + it('reports absent, not unchanged as a claimed clean, for a .bashrc that never had a block', async () => { + const fs = new MemFs({ [`${HOME}/.bashrc`]: 'export PATH="$PATH:/opt/tool/bin"\n' }); + const bash = findShell('bash')!; + const outcome = await uninstall(fs, bash, PLACES); + const byPath = actions(outcome.files); + expect(byPath[`${DATA}/completions/ailoud.bash`]).toBe('absent'); + expect(byPath[`${HOME}/.bashrc`]).toBe('unchanged'); + }); + + it('reports absent for a .bashrc that does not exist at all', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + const outcome = await uninstall(fs, bash, PLACES); + expect(actions(outcome.files)[`${HOME}/.bashrc`]).toBe('absent'); + }); + + it('preserves .bashrc that was empty before install: after uninstall, file exists empty and reports cleaned', async () => { + // A user may deliberately create an empty .bashrc to override a distro's + // default startup script. When ailoud installs into it, it adds a block and + // reports "updated". On uninstall, removing that block leaves it empty, but + // the file must not be deleted — it was not created by ailoud and must not + // be destroyed by uninstall. The outcome must be "cleaned", not "removed". + const fs = new MemFs({ [`${HOME}/.bashrc`]: '' }); + const bash = findShell('bash')!; + + // First install into the empty file + const installOutcome = await install(fs, bash, TREE, PLACES); + expect(actions(installOutcome.files)[`${HOME}/.bashrc`]).toBe('updated'); + + // Then uninstall + const uninstallOutcome = await uninstall(fs, bash, PLACES); + const byPath = actions(uninstallOutcome.files); + expect(byPath[`${HOME}/.bashrc`]).toBe('cleaned'); + expect(await fs.exists(`${HOME}/.bashrc`)).toBe(true); + expect(await fs.readTextFile(`${HOME}/.bashrc`)).toBe(''); + }); +}); + +describe('refresh', () => { + it('returns null when nothing is installed', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + expect(await refresh(fs, bash, TREE, PLACES)).toBeNull(); + }); + + it('rewrites a stale script when the block is present', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + await install(fs, bash, TREE, PLACES); + await fs.writeTextFile(`${DATA}/completions/ailoud.bash`, '# stale script\n'); + + const outcome = await refresh(fs, bash, TREE, PLACES); + expect(outcome).not.toBeNull(); + const script = await fs.readTextFile(`${DATA}/completions/ailoud.bash`); + expect(script).not.toContain('stale'); + expect(script).toContain('_ailoud'); + }); + + it('sweeps a shell the user no longer runs: a hand-written zsh block is still regenerated', async () => { + // The caller may know only that $SHELL currently names bash, but an + // earlier install may have left a block in .zshrc. refresh does not take + // the environment at all -- given the zsh target, it acts on zsh's own + // installed state regardless of what shell is running right now. + const zsh = findShell('zsh')!; + const zshBlockBody = zsh.rcBlockBody(`${DATA}/completions/_ailoud`); + const handWritten = [START, ...zshBlockBody, '# <<< ailoud completions <<<'].join('\n'); + const fs = new MemFs({ [`${HOME}/.zshrc`]: `${handWritten}\n` }); + + const outcome = await refresh(fs, zsh, TREE, PLACES); + expect(outcome).not.toBeNull(); + expect(await fs.exists(`${DATA}/completions/_ailoud`)).toBe(true); + expect(await fs.readTextFile(`${DATA}/completions/_ailoud`)).toContain('_ailoud'); + }); + + it('never installs something new for a shell with nothing of ours in it', async () => { + const fs = new MemFs({ [`${HOME}/.bashrc`]: 'export PATH="$PATH:/opt/tool/bin"\n' }); + const bash = findShell('bash')!; + expect(await refresh(fs, bash, TREE, PLACES)).toBeNull(); + expect(await fs.exists(`${DATA}/completions/ailoud.bash`)).toBe(false); + }); +}); + +describe('the startup file is written atomically', () => { + /** Records the order of writes and renames, so the mechanism is checkable. */ + class RecordingFs extends MemFs { + public readonly calls: string[] = []; + public override async writeTextFile(path: string, content: string): Promise { + this.calls.push(`write:${path}`); + return super.writeTextFile(path, content); + } + public override async rename(from: string, to: string): Promise { + this.calls.push(`rename:${from}->${to}`); + return super.rename(from, to); + } + } + + it('writes a temporary file and renames it over .bashrc rather than truncating it in place', async () => { + // The MECHANISM is what this asserts, deliberately -- see mcp/install.ts's + // `write` for the ENOSPC scenario `MemFs` cannot reproduce: a bare + // `writeTextFile` truncates the target before a failed write empties it, + // and the target here is the user's own `.bashrc`. + const fs = new RecordingFs({ [`${HOME}/.bashrc`]: '# my own notes\n' }); + const bash = findShell('bash')!; + + await install(fs, bash, TREE, PLACES); + + const rc = fs.calls.filter((call) => call.includes('.bashrc')); + expect(rc.some((call) => call.startsWith('write:') && call.includes('.tmp'))).toBe(true); + expect( + rc.some((call) => call.startsWith('rename:') && call.endsWith(`->${HOME}/.bashrc`)), + ).toBe(true); + expect(rc).not.toContain(`write:${HOME}/.bashrc`); + }); +}); diff --git a/apps/cli/src/completions/install.ts b/apps/cli/src/completions/install.ts new file mode 100644 index 0000000..7f2c00d --- /dev/null +++ b/apps/cli/src/completions/install.ts @@ -0,0 +1,230 @@ +import { randomUUID } from 'node:crypto'; +import { dirname } from 'node:path'; +import type { Fs } from '@ailoud/core'; +import { + blockRange as rangeIn, + hasBlock as hasIn, + withBlock as withIn, + withoutBlock as withoutIn, +} from '../markerBlock.js'; +import type { CommandNode, Shell } from './generate.js'; +import { renderCompletions } from './generate.js'; +import type { ShellTarget } from './shells.js'; + +/** + * The block ailoud writes into a shell's startup file. + * + * A `#` comment in every shell this table covers, so the same pair works for + * `.bashrc` and `.zshrc` without a second marker syntax to keep in step with + * `markerBlock.ts`'s pairing rule. + */ +export const START = '# >>> ailoud completions >>>'; +export const END = '# <<< ailoud completions <<<'; + +const MARKERS = { start: START, end: END }; + +/** Where our block sits, or null. The pairing rule that matters lives in markerBlock.ts. */ +export function blockRange(text: string): { readonly from: number; readonly to: number } | null { + return rangeIn(text, MARKERS); +} + +/** Whether a startup file already carries our block. */ +export function hasBlock(text: string): boolean { + return hasIn(text, MARKERS); +} + +/** The block body for one shell: the marker pair around the lines that source or register the script. */ +function block(target: ShellTarget, scriptPath: string): string { + return [START, ...target.rcBlockBody(scriptPath), END].join('\n'); +} + +/** What happened to one file, for the report a command prints. */ +export interface FileOutcome { + readonly path: string; + readonly action: 'created' | 'updated' | 'unchanged' | 'removed' | 'cleaned' | 'absent'; +} + +export interface ShellOutcome { + readonly shell: Shell; + readonly files: readonly FileOutcome[]; + /** The advisory from `ShellTarget.warnAbout`, or empty when there is none. */ + readonly note: string; +} + +/** The directories a script or startup file may need, resolved once by the caller. */ +export interface Places { + readonly home: string; + readonly configHome: string; + readonly userDataDir: string; +} + +async function readIfPresent(fs: Fs, path: string): Promise { + return (await fs.exists(path)) ? fs.readTextFile(path) : null; +} + +async function noteFor(fs: Fs, target: ShellTarget, home: string): Promise { + if (target.warnAbout === undefined) return ''; + return (await target.warnAbout(fs, home)) ?? ''; +} + +/** + * Writes a file without ever leaving it half-written: a temporary file beside + * it, then a rename over the top. + * + * `writeTextFile` truncates before it writes, so a failure part-way through -- + * ENOSPC is the realistic one -- leaves the target EMPTY. The files this + * writes into are `~/.bashrc` and `~/.zshrc`: files the user hand-edits and + * that every interactive shell they open reads at startup. Truncating one and + * then reporting a failure destroys their shell configuration while telling + * them nothing happened. + * + * Same pattern as `write` in `apps/cli/src/mcp/install.ts`, and for the same + * reason. The temporary name is randomised so two concurrent writers cannot + * corrupt each other's, and it sits in the target's own directory so the + * rename stays on one filesystem and therefore stays atomic. + */ +async function write(fs: Fs, path: string, content: string): Promise { + await fs.ensureDir(dirname(path)); + const temp = `${path}.${randomUUID()}.tmp`; + try { + await fs.writeTextFile(temp, content); + } catch (error) { + // The target has not been touched yet, so there is nothing to undo. Clear + // the partial temporary file rather than leaving litter beside a config. + await fs.removeFile(temp); + throw error; + } + await fs.rename(temp, path); +} + +/** + * Writes the completion script and, unless the shell needs none, wires it + * into the startup file. + * + * The script and the block are reported separately even though one install + * writes both: a caller sweeping every shell needs to say which file changed, + * and collapsing them into one outcome would lose that. + */ +export async function install( + fs: Fs, + target: ShellTarget, + tree: CommandNode, + places: Places, +): Promise { + const files: FileOutcome[] = []; + + const scriptPath = target.scriptPath(places.home, places.configHome, places.userDataDir); + const script = renderCompletions(target.shell, tree); + const scriptBefore = await readIfPresent(fs, scriptPath); + if (scriptBefore === null) { + await write(fs, scriptPath, script); + files.push({ path: scriptPath, action: 'created' }); + } else if (scriptBefore !== script) { + await write(fs, scriptPath, script); + files.push({ path: scriptPath, action: 'updated' }); + } else { + files.push({ path: scriptPath, action: 'unchanged' }); + } + + const rcPath = target.rcPath(places.home); + if (rcPath !== null) { + const rcBefore = await readIfPresent(fs, rcPath); + const rcAfter = withIn(rcBefore ?? '', block(target, scriptPath), MARKERS); + if (rcBefore === null) { + await write(fs, rcPath, rcAfter); + files.push({ path: rcPath, action: 'created' }); + } else if (rcBefore !== rcAfter) { + await write(fs, rcPath, rcAfter); + files.push({ path: rcPath, action: 'updated' }); + } else { + files.push({ path: rcPath, action: 'unchanged' }); + } + } + + return { shell: target.shell, files, note: await noteFor(fs, target, places.home) }; +} + +/** + * Removes the block from the startup file and deletes the script, leaving + * anything else in either file untouched. + * + * A rc file with no block of ours reports `absent`, not a cleanup it never + * did -- an uninstall that claims to have cleaned a file it never touched + * teaches the user to distrust it. + */ +export async function uninstall( + fs: Fs, + target: ShellTarget, + places: Places, +): Promise { + const files: FileOutcome[] = []; + + const scriptPath = target.scriptPath(places.home, places.configHome, places.userDataDir); + if (await fs.exists(scriptPath)) { + await fs.removeFile(scriptPath); + files.push({ path: scriptPath, action: 'removed' }); + } else { + files.push({ path: scriptPath, action: 'absent' }); + } + + const rcPath = target.rcPath(places.home); + if (rcPath !== null) { + const rcBefore = await readIfPresent(fs, rcPath); + if (rcBefore === null) { + files.push({ path: rcPath, action: 'absent' }); + } else { + const rcAfter = withoutIn(rcBefore, MARKERS); + if (rcAfter === null) { + files.push({ path: rcPath, action: 'unchanged' }); + } else { + // One branch, including when removing our block empties the file: the + // startup file is written back empty and never deleted. It may have + // been created empty on purpose -- to override a distro's default -- + // and deleting it is not reversible and was never asked for. This + // differs from mcp/install.ts deleting an MCP server config it created: + // that file is a tool's own config, a shell startup file is the user's + // property. Kept as one arm because the empty case and the rest do + // exactly the same thing, and two identical arms invite an edit to one + // that silently misses the other. + await write(fs, rcPath, rcAfter); + files.push({ path: rcPath, action: 'cleaned' }); + } + } + } + + return { shell: target.shell, files, note: await noteFor(fs, target, places.home) }; +} + +/** + * Rewrites what a previous install put in place for this shell, and touches + * nothing when this shell has nothing installed. + * + * Called once per shell in `SHELL_TARGETS` by the caller, not only for the + * one `$SHELL` currently names: an earlier install may have written into a + * shell the user has since stopped using, and a stale script left there keeps + * completing commands that no longer exist. `mcp/install.ts`'s `update` has + * the same rule, for the same reason, and this function does not look at the + * environment at all -- it acts on whichever `target` it is given. + */ +export async function refresh( + fs: Fs, + target: ShellTarget, + tree: CommandNode, + places: Places, +): Promise { + const rcPath = target.rcPath(places.home); + + let configured: boolean; + if (rcPath === null) { + // fish has no startup file to carry a block, so the script's own + // presence is the only signal that it was ever installed. + const scriptPath = target.scriptPath(places.home, places.configHome, places.userDataDir); + configured = await fs.exists(scriptPath); + } else { + const rcText = await readIfPresent(fs, rcPath); + configured = rcText !== null && hasIn(rcText, MARKERS); + } + + if (!configured) return null; + return install(fs, target, tree, places); +} diff --git a/apps/cli/src/completions/shells.test.ts b/apps/cli/src/completions/shells.test.ts new file mode 100644 index 0000000..ce0f66b --- /dev/null +++ b/apps/cli/src/completions/shells.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { MemFs } from '@ailoud/core/testing'; +import { SHELL_TARGETS, detect, findShell } from './shells.js'; + +const HOME = '/home/ann'; +const CONFIG = '/home/ann/.config'; +const DATA = '/home/ann/.local/share/ailoud'; + +describe('shell targets', () => { + it('covers exactly bash, zsh and fish', () => { + expect(SHELL_TARGETS.map((t) => t.shell)).toEqual(['bash', 'zsh', 'fish']); + }); + + it('puts the script under the user data directory, not a project library', () => { + // A completion script is a property of the user's shell, not of one + // repository. Under a project's .ailoud/ it would be installed once per + // repository and lost on the next cd. + const bash = findShell('bash')!; + expect(bash.scriptPath(HOME, CONFIG, DATA)).toBe(`${DATA}/completions/ailoud.bash`); + }); + + it('puts fish under its own completions directory and needs no rc edit', () => { + const fish = findShell('fish')!; + expect(fish.scriptPath(HOME, CONFIG, DATA)).toBe(`${CONFIG}/fish/completions/ailoud.fish`); + expect(fish.rcPath(HOME)).toBeNull(); + }); + + it('names the zsh script _ailoud, which is what fpath lookup requires', () => { + const zsh = findShell('zsh')!; + expect(zsh.scriptPath(HOME, CONFIG, DATA)).toBe(`${DATA}/completions/_ailoud`); + expect(zsh.rcPath(HOME)).toBe(`${HOME}/.zshrc`); + const body = zsh.rcBlockBody(`${DATA}/completions/_ailoud`).join('\n'); + expect(body).toContain('fpath'); + expect(body).toContain('compinit'); + }); + + it('never writes compinit -u, which would trust every insecure fpath directory', () => { + // `-u` tells compinit to load completion functions from every + // group-writable directory in fpath instead of refusing them. A Homebrew + // user with a group-writable /opt/homebrew/share/zsh/site-functions would + // start sourcing every `_*` file there at each shell start, because ailoud + // edited their .zshrc. `-i` skips those directories and still never + // prompts, which is all the block needed. + const body = findShell('zsh')!.rcBlockBody(`${DATA}/completions/_ailoud`).join('\n'); + expect(body).toContain('compinit -i'); + expect(body).not.toContain('compinit -u'); + }); + + it('re-runs compinit only when nothing else already did', () => { + // oh-my-zsh and prezto run compinit before the end of .zshrc, where this + // block lands. Re-running it there rebuilds a table of ~1700 entries to + // add one; `compdef` registers the single function instead. The branch is + // needed at all because adding to fpath after compinit has run registers + // nothing -- compinit reads fpath once. + const body = findShell('zsh')!.rcBlockBody(`${DATA}/completions/_ailoud`).join('\n'); + expect(body).toContain('${+_comps}'); + expect(body).toContain('compdef _ailoud ailoud'); + }); + + it('sources the bash script from .bashrc', () => { + const bash = findShell('bash')!; + expect(bash.rcPath(HOME)).toBe(`${HOME}/.bashrc`); + expect(bash.rcBlockBody('/x/ailoud.bash').join('\n')).toContain('/x/ailoud.bash'); + }); +}); + +describe('detect', () => { + const env = (shell?: string) => (shell === undefined ? {} : { SHELL: shell }); + + it('counts a shell present when its startup file exists', async () => { + const fs = new MemFs({}); + const zsh = findShell('zsh')!; + expect(await detect(fs, zsh, HOME, CONFIG, env())).toBe(false); + await fs.writeTextFile(`${HOME}/.zshrc`, ''); + expect(await detect(fs, zsh, HOME, CONFIG, env())).toBe(true); + }); + + it('counts a shell present when $SHELL names it, with no files at all', async () => { + // A freshly installed shell with no rc file yet is exactly the user most + // helped by having completions set up for them. + const fs = new MemFs({}); + expect(await detect(fs, findShell('fish')!, HOME, CONFIG, env('/usr/bin/fish'))).toBe(true); + }); + + it('does not match a shell whose name merely appears inside another path', async () => { + // A naive substring match (env.includes(target.shell)) would see "fish" + // inside "fisherman" and wrongly count fish as present for a bash user + // whose home or shell path happens to contain those letters. Comparing + // /usr/bin/bash against fish proves nothing here -- "bash" contains no + // "fish" under either strategy -- so the path below must actually embed + // "fish" as a substring without the basename being fish. + const fs = new MemFs({}); + expect( + await detect(fs, findShell('fish')!, HOME, CONFIG, env('/home/fisherman/bin/bash')), + ).toBe(false); + }); + + it('accepts either bash startup file', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + await fs.writeTextFile(`${HOME}/.bash_profile`, ''); + expect(await detect(fs, bash, HOME, CONFIG, env())).toBe(true); + }); + + it('counts a shell present when its binary is on $PATH, with no rc file and another $SHELL', async () => { + // The third signal the design lists, and the one the other two miss: a + // user who installed fish but has never launched it has no + // ~/.config/fish/ and still has $SHELL=/bin/zsh. That is precisely the + // user the signal exists for. + const fs = new MemFs({}); + const fish = findShell('fish')!; + const at = { SHELL: '/bin/zsh', PATH: '/usr/bin:/opt/homebrew/bin' }; + expect(await detect(fs, fish, HOME, CONFIG, at)).toBe(false); + await fs.writeTextFile('/opt/homebrew/bin/fish', ''); + expect(await detect(fs, fish, HOME, CONFIG, at)).toBe(true); + }); + + it('does not read an empty $PATH entry as the current directory', async () => { + // POSIX reads an empty element as ".", so resolving one would ask about + // ./fish and call the shell present because the user happened to be + // standing in a directory holding a file of that name. + const fs = new MemFs({}); + await fs.writeTextFile('fish', ''); + expect(await detect(fs, findShell('fish')!, HOME, CONFIG, { PATH: ':/usr/bin' })).toBe(false); + }); +}); + +describe('warnAbout', () => { + it('says nothing when .bash_profile does not exist', async () => { + const fs = new MemFs({}); + const bash = findShell('bash')!; + expect(await bash.warnAbout!(fs, HOME)).toBeNull(); + }); + + it('says nothing when .bash_profile already sources .bashrc', async () => { + const fs = new MemFs({ [`${HOME}/.bash_profile`]: '[ -f ~/.bashrc ] && source ~/.bashrc\n' }); + const bash = findShell('bash')!; + expect(await bash.warnAbout!(fs, HOME)).toBeNull(); + }); + + it('warns when .bash_profile exists and never mentions .bashrc', async () => { + const fs = new MemFs({ [`${HOME}/.bash_profile`]: 'export PATH="$PATH:/opt/tool/bin"\n' }); + const bash = findShell('bash')!; + const warning = await bash.warnAbout!(fs, HOME); + expect(warning).not.toBeNull(); + expect(warning).toContain('.bash_profile'); + }); + + it('warns when the only mention of .bashrc is commented out', async () => { + // A disabled line reads as "not wired in" -- if it counted as handled, + // the user would get no warning and completions would silently never + // load, discoverable only by a confused "why doesn't Tab work" report. + const fs = new MemFs({ + [`${HOME}/.bash_profile`]: '# used to source .bashrc, stopped\nexport PATH="$PATH:/x"\n', + }); + const bash = findShell('bash')!; + expect(await bash.warnAbout!(fs, HOME)).not.toBeNull(); + }); + + it('is not implemented for zsh or fish', () => { + expect(findShell('zsh')!.warnAbout).toBeUndefined(); + expect(findShell('fish')!.warnAbout).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/completions/shells.ts b/apps/cli/src/completions/shells.ts new file mode 100644 index 0000000..e674540 --- /dev/null +++ b/apps/cli/src/completions/shells.ts @@ -0,0 +1,194 @@ +import { basename, delimiter, dirname, join } from 'node:path'; +import type { Fs } from '@ailoud/core'; +import { SHELLS, type Shell } from './generate.js'; + +export interface ShellTarget { + readonly shell: Shell; + readonly label: string; + /** Where the generated script goes. */ + scriptPath(home: string, configHome: string, userDataDir: string): string; + /** + * The startup file to wire the script into, or null when the shell needs + * none. fish autoloads its completions directory, so it needs none. + */ + rcPath(home: string): string | null; + /** The line(s) that go inside the marker block, sourcing the script. */ + rcBlockBody(scriptPath: string): readonly string[]; + /** Paths whose existence suggests this shell is in use. */ + detectPaths(home: string, configHome: string): readonly string[]; + /** + * Whether this shell's startup file will actually be read. + * + * An interactive LOGIN bash on macOS reads `~/.bash_profile` and never + * `~/.bashrc`, so a block written to `.bashrc` alone is a file the shell + * never opens -- an install that reports success and does nothing. Rather + * than edit a second startup file (two files edited for one shell is harder + * to undo than it is to explain), the caller is told, and decides. + * + * Null when there is nothing to say. Non-null is a sentence for the user. + */ + warnAbout?(fs: Fs, home: string): Promise; +} + +/** + * Whether `content` already wires `.bashrc` into a login shell's startup, in + * any of the ways people actually write that line. + * + * Comment lines are skipped before the substring test: a `.bash_profile` + * whose only mention is `# used to source .bashrc, stopped` is not wiring + * anything in, and reading it as "already handled" would leave the user with + * no warning and completions that silently never load. This still only + * strips whole-line `#` comments, not a trailing `# ...` after real content + * or bash's other comment forms -- deliberately not a bash parser, just + * enough to not be fooled by the one shape a disabled line actually takes. + */ +function mentionsBashrc(content: string): boolean { + return content + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')) + .some((line) => line.includes('.bashrc')); +} + +async function bashWarnAbout(fs: Fs, home: string): Promise { + const profile = join(home, '.bash_profile'); + if (!(await fs.exists(profile))) return null; + const content = await fs.readTextFile(profile); + if (mentionsBashrc(content)) return null; + return ( + '~/.bash_profile exists and does not source ~/.bashrc, so an interactive login ' + + 'bash (the default on macOS Terminal) will not read the completions block just ' + + 'written to ~/.bashrc. Add "[ -f ~/.bashrc ] && source ~/.bashrc" to ~/.bash_profile, ' + + 'or open a non-login shell to pick up the change.' + ); +} + +/** + * The shells ailoud can wire completions into. + * + * Every path and startup-file name here was read from a working install of + * that shell rather than from memory: guessing one writes a script nothing + * sources, which looks exactly like a successful install and is only + * discovered when Tab does nothing. + */ +export const SHELL_TARGETS: readonly ShellTarget[] = [ + { + shell: 'bash', + label: 'Bash', + scriptPath: (_home, _configHome, userDataDir) => + join(userDataDir, 'completions', 'ailoud.bash'), + rcPath: (home) => join(home, '.bashrc'), + rcBlockBody: (scriptPath) => [`[ -f "${scriptPath}" ] && source "${scriptPath}"`], + // Either startup file counts as "bash is in use": a fresh install may + // carry only .bash_profile, and an existing one only .bashrc. + detectPaths: (home) => [join(home, '.bashrc'), join(home, '.bash_profile')], + warnAbout: bashWarnAbout, + }, + { + shell: 'zsh', + label: 'Zsh', + scriptPath: (_home, _configHome, userDataDir) => join(userDataDir, 'completions', '_ailoud'), + rcPath: (home) => join(home, '.zshrc'), + // zsh completions are functions named `_ailoud`, found via `fpath` rather + // than sourced directly; the completion system is what makes zsh look them + // up at all. + // + // Two branches, because adding to `fpath` after compinit has already run + // registers nothing -- compinit scans `fpath` once and builds `_comps` from + // what it finds. `_comps` set is therefore the reliable "someone already + // ran compinit" signal (oh-my-zsh and prezto both do, before the end of + // .zshrc where this block lands), and in that case `compdef` registers the + // one function directly instead of re-running compinit and rebuilding a + // table of ~1700 entries for a single addition. + // + // `compinit -i`, never `-u`. `-u` means "use every insecure directory in + // fpath without asking", which silently re-enables completion files zsh was + // deliberately refusing -- a Homebrew user with a group-writable + // /opt/homebrew/share/zsh/site-functions would start sourcing every `_*` + // file there at each shell start because ailoud edited their .zshrc. `-i` + // reaches the same goal (never prompt during startup) by skipping the + // insecure directories instead of trusting them. + rcBlockBody: (scriptPath) => [ + `fpath=("${dirname(scriptPath)}" $fpath)`, + 'if (( ${+_comps} )); then', + ' autoload -Uz _ailoud && compdef _ailoud ailoud', + 'else', + ' autoload -Uz compinit && compinit -i', + 'fi', + ], + detectPaths: (home) => [join(home, '.zshrc')], + }, + { + shell: 'fish', + label: 'Fish', + // fish autoloads every file under its own completions directory, keyed + // by the XDG config home rather than the user data directory the other + // two shells use. + scriptPath: (_home, configHome, _userDataDir) => + join(configHome, 'fish', 'completions', 'ailoud.fish'), + rcPath: () => null, + rcBlockBody: () => [], + detectPaths: (_home, configHome) => [join(configHome, 'fish', 'config.fish')], + }, +]; + +// Keeps this table from drifting out of sync with the renderer: generate.ts +// would silently produce no script for a shell missing here, and TypeScript +// cannot catch that because SHELL_TARGETS is a plain array, not a record +// keyed by Shell. +if ( + SHELL_TARGETS.length !== SHELLS.length || + SHELL_TARGETS.some((target, index) => target.shell !== SHELLS[index]) +) { + throw new Error('SHELL_TARGETS is out of sync with SHELLS in ./generate.js'); +} + +export function findShell(id: string): ShellTarget | undefined { + const wanted = id.trim().toLowerCase(); + return SHELL_TARGETS.find((target) => target.shell === wanted); +} + +export function shellIds(): string { + return SHELL_TARGETS.map((target) => target.shell).join(', '); +} + +/** + * Whether `target`'s shell looks like one the user actually has. + * + * Three independent signals, any one sufficient: a startup file already on + * disk, the shell's binary somewhere on `$PATH`, or `$SHELL` naming it. + * + * The binary on `$PATH` is the signal the other two miss, and the design says + * so explicitly: a user who installed fish but has not launched it yet has no + * `~/.config/fish/` and still has `$SHELL=/bin/zsh`, and is precisely the user + * most helped by having completions set up for them. Requiring a startup file + * would offer nothing until after they had configured the shell by hand. + * + * `$SHELL` is compared by basename, not by substring -- `/usr/bin/bash` + * contains none of the letters "fish", but a home directory such as + * `/home/fisherman` or a shell path containing another shell's name as a + * substring is exactly the false positive a substring match invites, and + * basename comparison against the full shell name sidesteps it. The `$PATH` + * walk joins the shell name onto each entry for the same reason: it asks + * whether that exact file exists, never whether some path contains the word. + */ +export async function detect( + fs: Fs, + target: ShellTarget, + home: string, + configHome: string, + env: Record, +): Promise { + for (const path of target.detectPaths(home, configHome)) { + if (await fs.exists(path)) return true; + } + const shellEnv = env['SHELL']; + if (shellEnv !== undefined && basename(shellEnv) === target.shell) return true; + // Empty entries are skipped rather than resolved: POSIX reads an empty + // `$PATH` element as the current directory, so `join('', 'fish')` would ask + // about `./fish` and report the shell present because the user happened to + // be standing in a directory holding a file of that name. + for (const dir of (env['PATH'] ?? '').split(delimiter)) { + if (dir !== '' && (await fs.exists(join(dir, target.shell)))) return true; + } + return false; +} diff --git a/apps/cli/src/config.test.ts b/apps/cli/src/config.test.ts index dda0b81..70eb460 100644 --- a/apps/cli/src/config.test.ts +++ b/apps/cli/src/config.test.ts @@ -1,25 +1,31 @@ import { describe, expect, it } from 'vitest'; import { EnvironmentError } from '@ailoud/core'; -import { parseConfig, resolvePaths } from './config.js'; +import { ConfigSchema, parseConfig, resolvePaths } from './config.js'; describe('resolvePaths', () => { it('honours both XDG variables', () => { expect(resolvePaths({ XDG_CONFIG_HOME: '/c', XDG_DATA_HOME: '/d', HOME: '/h' })).toEqual({ configFile: '/c/ailoud/config.yaml', + configHome: '/c', dataDir: '/d/ailoud', dbFile: '/d/ailoud/ailoud.db', mediaRoot: '/d/ailoud/media', + jobsDir: '/d/ailoud/jobs', isProjectLibrary: false, + userDataDir: '/d/ailoud', }); }); it('falls back to the documented defaults under HOME', () => { expect(resolvePaths({ HOME: '/h' })).toEqual({ configFile: '/h/.config/ailoud/config.yaml', + configHome: '/h/.config', dataDir: '/h/.local/share/ailoud', dbFile: '/h/.local/share/ailoud/ailoud.db', mediaRoot: '/h/.local/share/ailoud/media', + jobsDir: '/h/.local/share/ailoud/jobs', isProjectLibrary: false, + userDataDir: '/h/.local/share/ailoud', }); }); @@ -27,6 +33,31 @@ describe('resolvePaths', () => { expect(() => resolvePaths({})).toThrow(/HOME/); expect(() => resolvePaths({})).toThrow(EnvironmentError); }); + + it('reports the per-user data directory even inside a project', () => { + const paths = resolvePaths( + { HOME: '/home/x', XDG_DATA_HOME: '/home/x/.local/share' }, + { cwd: '/repo/sub', exists: (p) => p === '/repo/.ailoud' }, + ); + // dataDir follows the project; userDataDir must not. The registry, the + // update-check cache and the log all live per user, and a registry inside + // a project would list only that project. + expect(paths.dataDir).toBe('/repo/.ailoud'); + expect(paths.userDataDir).toBe('/home/x/.local/share/ailoud'); + }); + + it('resolves configHome by the same empty and relative rules as configFile', () => { + // An exported-but-empty XDG_CONFIG_HOME means "use the default", and a + // relative one is invalid and must be ignored -- see absoluteOr above. + // configHome is read from the same local configFile is built from, so + // both must move together rather than configHome quietly reading the + // environment a second time under different rules. + expect(resolvePaths({ HOME: '/h', XDG_CONFIG_HOME: '' }).configHome).toBe('/h/.config'); + expect(resolvePaths({ HOME: '/h', XDG_CONFIG_HOME: 'relative/path' }).configHome).toBe( + '/h/.config', + ); + expect(resolvePaths({ HOME: '/h', XDG_CONFIG_HOME: '/c' }).configHome).toBe('/c'); + }); }); describe('parseConfig', () => { @@ -45,7 +76,7 @@ describe('parseConfig', () => { segmentationModel: null, embeddingModel: null, threshold: 0.6, - threads: 4, + threads: null, }, }, llm: { @@ -57,7 +88,7 @@ describe('parseConfig', () => { model: null, contextTokens: 8192, maxOutputTokens: 1024, - threads: 4, + threads: null, }, openaiCompatible: { baseUrl: 'https://api.openai.com/v1', @@ -77,9 +108,23 @@ describe('parseConfig', () => { contextTokens: 200_000, }, }, + resources: { + maxCpuPercent: 90, + gpu: true, + }, + audio: { + denoise: 'off', + }, + update: { + check: true, + }, }); }); + it('defaults the update check to on', () => { + expect(ConfigSchema.parse({}).update.check).toBe(true); + }); + it('reads the whisper binary and model', () => { const config = parseConfig( 'stt:\n provider: whisper-cpp\n whisperCpp:\n binary: /opt/whisper\n model: /m/base.bin\n', @@ -137,7 +182,7 @@ describe('parseConfig', () => { segmentationModel: '/m/seg.onnx', embeddingModel: null, threshold: 0.6, - threads: 4, + threads: null, }); }); @@ -150,7 +195,7 @@ describe('parseConfig', () => { segmentationModel: null, embeddingModel: null, threshold: 0.8, - threads: 4, + threads: null, }); }); @@ -183,3 +228,44 @@ describe('parseConfig', () => { expect(() => parseConfig('stt: [unclosed')).toThrow(/config/i); }); }); + +describe('resource and audio configuration', () => { + it('defaults to 90 percent, the gpu on, and denoising off', () => { + const config = parseConfig(''); + expect(config.resources).toEqual({ maxCpuPercent: 90, gpu: true }); + expect(config.audio).toEqual({ denoise: 'off' }); + }); + + it('fills in the rest of a partially written resources block', () => { + // Zod 4's .default() short-circuits on a nested object and would drop + // `gpu` here; .prefault({}) re-parses and keeps the inner defaults. See + // the note at the top of config.ts. + const config = parseConfig('resources:\n maxCpuPercent: 50\n'); + expect(config.resources).toEqual({ maxCpuPercent: 50, gpu: true }); + }); + + it.each([0, 101, -1, 3.5])('refuses a percent of %s', (percent) => { + expect(() => parseConfig(`resources:\n maxCpuPercent: ${percent}\n`)).toThrow(); + }); + + it('refuses an unknown denoise mode', () => { + expect(() => parseConfig('audio:\n denoise: sometimes\n')).toThrow(); + }); + + it.each(['auto', 'on', 'off'])('accepts the %s denoise mode', (mode) => { + expect(parseConfig(`audio:\n denoise: ${mode}\n`).audio.denoise).toBe(mode); + }); + + it('leaves both thread overrides null by default, meaning follow the budget', () => { + const config = parseConfig(''); + expect(config.stt.diarization.threads).toBeNull(); + expect(config.llm.llamaCpp.threads).toBeNull(); + }); + + it('keeps a thread count someone wrote down', () => { + // The reason the key stays nullable rather than being deleted: a user who + // measured their own machine outranks a constant measured on one laptop. + const config = parseConfig('stt:\n diarization:\n threads: 4\n'); + expect(config.stt.diarization.threads).toBe(4); + }); +}); diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 4167469..5e5be00 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -1,6 +1,6 @@ import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; -import { EnvironmentError, LLM_PROVIDERS, UsageError } from '@ailoud/core'; +import { DENOISE_MODES, EnvironmentError, LLM_PROVIDERS, UsageError } from '@ailoud/core'; // Zod 4's `.default()` short-circuits: when the key is missing it substitutes // the default value as-is, without re-running it through the inner schema. @@ -9,7 +9,7 @@ import { EnvironmentError, LLM_PROVIDERS, UsageError } from '@ailoud/core'; // contradicts the requirement that `.default({})` fills in the rest, so // nested objects use `.prefault()` instead, which re-parses the default // value through the inner schema (the pre-Zod-4 `.default()` behaviour). -const ConfigSchema = z.object({ +export const ConfigSchema = z.object({ stt: z .object({ provider: z.enum(['whisper-cpp']).default('whisper-cpp'), @@ -27,16 +27,14 @@ const ConfigSchema = z.object({ segmentationModel: z.string().nullable().default(null), embeddingModel: z.string().nullable().default(null), threshold: z.number().default(0.6), - // Threads for both diarizer passes (segmentation and embedding). - // The binary itself defaults to 1, which halves the throughput the - // design measured and the README quotes: 16 s of audio in 2.58 s on - // one thread against 57 s in 5.19 s on four. Four is the default - // here because it is the configuration those numbers were taken on, - // and because any machine that can run a 465 MB whisper model has - // four cores. Configurable rather than baked into the adapter: a - // 2-core VM wants fewer, and a workstation transcribing a long - // meeting wants more. - threads: z.number().int().min(1).default(4), + /** + * Null means: follow the resource budget, which gives this engine a + * share capped below the ceiling because it has an optimum rather + * than a maximum (the curve is in sherpaDiarizer.ts). A number is a + * hard override, exempt from the cap -- someone who measured their + * own machine outranks a constant measured on one laptop. + */ + threads: z.number().int().min(1).nullable().default(null), }) .prefault({}), }) @@ -53,7 +51,8 @@ const ConfigSchema = z.object({ model: z.string().nullable().default(null), contextTokens: z.number().int().min(512).default(8192), maxOutputTokens: z.number().int().min(64).default(1024), - threads: z.number().int().min(1).default(4), + /** Null means: follow the resource budget. A number overrides it. */ + threads: z.number().int().min(1).nullable().default(null), }) .prefault({}), openaiCompatible: z @@ -84,17 +83,73 @@ const ConfigSchema = z.object({ .prefault({}), }) .prefault({}), + resources: z + .object({ + /** + * A ceiling, not a target. Every engine gets at most this share of the + * machine's performance cores; engines measured to slow down past a + * lower count get a capped share of it (see core/resources/budget.ts). + */ + maxCpuPercent: z.number().int().min(1).max(100).default(90), + /** + * True adds no flag at all -- a homebrew whisper-cli already loads + * Metal on its own. False adds `-ng` where a binary has one. + */ + gpu: z.boolean().default(true), + }) + .prefault({}), + audio: z + .object({ + /** + * Off by default, on measurement. `auto` measures the converted wav and + * denoises when its signal-to-noise ratio is below the threshold in + * core/audio/noise.ts -- and a benchmark of that behaviour over six + * corpora found no case where denoising improved a transcript, and + * several where it hurt. The numbers and the reasoning are in that + * file's own comment; the short version is that whisper is already + * robust to the noise this chain removes, and the chain takes real + * speech with it. + * + * The modes are kept because `on` is a legitimate thing to ask for on a + * recording somebody has listened to. Only the default changed. + */ + denoise: z.enum(DENOISE_MODES).default('off'), + }) + .prefault({}), + update: z + .object({ + /** Look for a newer version once a day and mention it after a command. */ + check: z.boolean().default(true), + }) + .prefault({}), }); export type AiloudConfig = z.infer; export interface AiloudPaths { readonly configFile: string; + /** + * The XDG config home, resolved by the same rules `configFile` uses. fish + * keeps its completions under it, and reading `XDG_CONFIG_HOME` a second + * time somewhere else would drop the empty-means-default and + * relative-is-invalid rules this function is careful about. + */ + readonly configHome: string; readonly dataDir: string; readonly dbFile: string; readonly mediaRoot: string; + /** Where background jobs keep their state and log files. */ + readonly jobsDir: string; /** True when the library came from a project's `.ailoud/`, not the user's home. */ readonly isProjectLibrary: boolean; + /** + * The user's own `/ailoud`, regardless of `dataDir`. Things that + * are properties of the user rather than of a project -- the registry of + * projects ailoud has been used in, the update-check cache, the update log + * -- read and write here so that being inside a project library never + * scopes them down to that one project. + */ + readonly userDataDir: string; } /** The directory name a project uses to keep its own library. */ @@ -147,7 +202,7 @@ export interface ResolvePathsOptions { * * The CONFIG stays per-user either way. It names installed binaries, model * files and an LLM provider, none of which is a property of a project, and - * making it local would mean re-downloading a 488 MB model per repository. + * making it local would mean re-downloading a 574 MB model per repository. */ export function resolvePaths( env: Record, @@ -178,10 +233,13 @@ export function resolvePaths( return { configFile: `${configHome}/ailoud/config.yaml`, + configHome, dataDir, dbFile: `${dataDir}/ailoud.db`, mediaRoot: `${dataDir}/media`, + jobsDir: `${dataDir}/jobs`, isProjectLibrary: project !== null, + userDataDir: `${dataHome}/ailoud`, }; } diff --git a/apps/cli/src/configWrite.test.ts b/apps/cli/src/configWrite.test.ts index 3ce1785..9ed0982 100644 --- a/apps/cli/src/configWrite.test.ts +++ b/apps/cli/src/configWrite.test.ts @@ -89,7 +89,7 @@ describe('applyConfigUpdates', () => { // broken node and the edited node are different), but toString() refuses // to serialize a document carrying parse errors. Before this was // rewrapped, the user got a bare "Document with errors cannot be - // stringified" -- after downloading up to 1.6 GB, with no filename and + // stringified" -- after downloading up to 3.1 GB, with no filename and // no hint at what to fix. const source = 'stt:\n whisperCpp:\n binary: w\nother: [1, 2\n'; let thrown: unknown; diff --git a/apps/cli/src/configWrite.ts b/apps/cli/src/configWrite.ts index cd0eafb..b23fe10 100644 --- a/apps/cli/src/configWrite.ts +++ b/apps/cli/src/configWrite.ts @@ -104,7 +104,7 @@ export function applyConfigUpdates(source: string | null, updates: ConfigUpdates // // Rewrapped for the same reason the setIn failure above is: yaml's own // "Document with errors cannot be stringified" names neither the file nor - // the problem, and this is reached after a download of up to 1.6 GB, so + // the problem, and this is reached after a download of up to 3.1 GB, so // it is the last message the user gets and has to be actionable on its own. // // `doc.errors` is what actually distinguishes the two causes: it is only diff --git a/apps/cli/src/exclusiveLock.ts b/apps/cli/src/exclusiveLock.ts new file mode 100644 index 0000000..58d0a2e --- /dev/null +++ b/apps/cli/src/exclusiveLock.ts @@ -0,0 +1,205 @@ +import { mkdir, open, readFile, rm } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { FailureError } from '@ailoud/core'; + +/** + * The lock mechanism, shared by provisioning and by background jobs. + * + * Extracted rather than copied. The takeover path here was wrong twice + * before it was right (see withExclusiveLock), and a second copy of it would + * be wrong again in a way nobody would notice -- exactly the drift that made + * summarizeRun.ts exist. + */ + +/** What a held lock records, so a human can see who holds it. */ +export interface LockHolder { + readonly pid: number; + readonly startedAt: string; +} + +/** The messages a caller wants for each way `withExclusiveLock` can refuse. */ +export interface LockMessages { + readonly busy: (holder: LockHolder) => string; + readonly stealing: string; + readonly raced: string; +} + +/** + * Whether the process behind a pid is still running. + * + * Signal 0 performs the permission and existence checks without delivering + * anything. ESRCH means no such process, so the lock (or job) is stale. EPERM + * means the process EXISTS but belongs to another user -- alive, and the most + * dangerous case to get wrong, because treating it as stale would let two + * runs proceed at once, which is the whole thing a lock exists to prevent. + * + * Exported so `jobs/store.ts`'s `withLiveness` and the e2e suite's cleanup + * share this one implementation instead of each carrying their own copy -- + * this file's own header explains why a second copy of the lock logic is + * exactly the mistake that made extracting it out of `setupLock.ts` necessary + * in the first place. + */ +export function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** + * Reads a lock file, or reports it unreadable. + * + * A file that exists but is empty, truncated, or not valid JSON is a run + * that died between creating the lock and writing to it. That is stale by + * definition, and must not surface to the user as a parse error about a + * file they have never heard of. + */ +async function readHolder(path: string): Promise { + try { + const raw = await readFile(path, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return null; + const { pid, startedAt } = parsed as Partial; + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null; + if (typeof startedAt !== 'string' || startedAt === '') return null; + return { pid, startedAt }; + } catch { + return null; + } +} + +/** + * The lock's live holder, or null when the lock is free or stale. + * + * Separate from taking the lock, and advisory only: a caller that wants to + * refuse rather than block reads this, and must accept that the answer can + * be out of date by the time it acts. See the parent/child split in the + * design -- the parent reads this to give a synchronous refusal, and the + * child still takes the real lock. + */ +export async function readLockHolder(path: string): Promise { + const holder = await readHolder(path); + if (holder === null) return null; + return isRunning(holder.pid) ? holder : null; +} + +/** + * Takes an exclusive lock for the duration of `body`. + * + * Acquired by creating the file with the `wx` flag, which fails if it + * already exists. That is one atomic syscall; a check followed by a create + * would leave a window for the other process to win in between, which is + * exactly the race being closed. + * + * Taking over a stale lock cannot use `wx`, since the file is there, so the + * takeover runs under a second lock (`.steal`) created with `wx`. That + * makes the takeover itself exclusive -- one winner, and the loser told to + * try again. Checking afterwards who won is not enough: two runs can each + * check after their own write and each see themselves. + * + * A live lock is refused immediately rather than waited on. A caller that + * cannot proceed is better told so than left watching a queued run that is, + * from the outside, indistinguishable from a hung one -- each caller's own + * doc comment gives its specific reason for that. The refusal names the + * holder's pid and start time so the user can decide whether to wait or go + * and look at it. + * + * A stale lock is taken over. After a crash or a Ctrl-C that skipped + * cleanup, the file outlives its process, and a lock nobody can ever + * release would be worse than no lock at all. + */ +export async function withExclusiveLock( + path: string, + messages: LockMessages, + body: () => Promise, +): Promise { + await mkdir(dirname(path), { recursive: true }); + + const holder: LockHolder = { pid: process.pid, startedAt: new Date().toISOString() }; + const mine = JSON.stringify(holder); + + try { + const handle = await open(path, 'wx'); + try { + await handle.writeFile(mine, 'utf8'); + } finally { + await handle.close(); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + + const existing = await readHolder(path); + if (existing !== null && isRunning(existing.pid)) { + throw new FailureError(messages.busy(existing)); + } + + // Stale: the holder is gone, or never finished writing who it was. + // + // Two earlier attempts at this were wrong in the same way -- they made the + // takeover look exclusive without making it exclusive. `rm` then create + // let a second run become a live holder in between and then deleted its + // lock. Rename-then-read-back looked safer but is not: the interleaving + // A.rename, A.read, B.rename, B.read leaves each run reading its own pid, + // and both proceed. A concurrency test found 34 overlaps in 60 runs. + // + // Exclusion has to be on the takeover itself, so it runs through a second + // lock created with `wx` -- one atomic syscall, one winner. The loser is + // told to try again rather than being allowed to guess. + const steal = `${path}.steal`; + let stealHandle; + try { + stealHandle = await open(steal, 'wx'); + } catch (stealError) { + if ((stealError as NodeJS.ErrnoException).code !== 'EEXIST') throw stealError; + throw new FailureError(messages.stealing); + } + + try { + // Re-read under the steal lock: between the check above and here, the + // stale lock may have been taken by a run that is now alive. + const current = await readHolder(path); + if (current !== null && isRunning(current.pid)) { + throw new FailureError(messages.busy(current)); + } + await rm(path, { force: true }); + // Still `wx`: a run on the fast path can create the lock in the instant + // after that `rm`, and it is then the holder. Losing to it is the + // correct outcome, not something to overwrite. + // + // CodeQL reads this as a check-then-use race (js/file-system-race) and + // cannot see that the exclusion is held by `${path}.steal` above. The + // rule is excluded in .github/codeql/codeql-config.yml, where the + // reasoning lives; what actually guards this is the two-process test in + // setupLock.test.ts. + try { + const handle = await open(path, 'wx'); + try { + await handle.writeFile(mine, 'utf8'); + } finally { + await handle.close(); + } + } catch (createError) { + if ((createError as NodeJS.ErrnoException).code !== 'EEXIST') throw createError; + throw new FailureError(messages.raced); + } + } finally { + await stealHandle.close(); + await rm(steal, { force: true }); + } + } + + try { + return await body(); + } finally { + // Only our own lock. Removing it unconditionally would delete the lock of + // a run that legitimately took over after ours was declared stale, letting + // a third run in while that one is still working. + // + // force: a lock already gone is the outcome we wanted anyway, and failing + // to clean up must never mask what the body was doing. + const held = await readHolder(path); + if (held === null || held.pid === process.pid) await rm(path, { force: true }); + } +} diff --git a/apps/cli/src/jobs/loadJob.test.ts b/apps/cli/src/jobs/loadJob.test.ts new file mode 100644 index 0000000..ead0e2d --- /dev/null +++ b/apps/cli/src/jobs/loadJob.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { context } from '../commands/testContext.js'; +import { loadJob } from './loadJob.js'; +import { readJobState, writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +function jobState(partial: Partial = {}): JobState { + return { + id: '01LOADJOBTESTTESTTESTTESTTE', + kind: 'transcribe', + state: 'running', + percent: 40, + stage: 'transcribing', + // A pid nothing alive holds, distinct from this test process's own -- + // stands in for the launcher's now-exited pid `createJob` wrote before + // this process existed to claim it. + pid: 2_147_483_600, + startedAt: '2026-09-07T08:00:00.000Z', + finishedAt: null, + recordings: { total: 1, done: 0 }, + declared: null, + log: '/d/jobs/01LOADJOBTESTTESTTESTTESTTE.log', + result: null, + error: null, + ...partial, + }; +} + +describe('loadJob', () => { + it('returns undefined when --job was not given', async () => { + const ctx = context(); + await expect(loadJob(ctx, undefined)).resolves.toBeUndefined(); + }); + + it('throws a UsageError naming an id with no such job', async () => { + const ctx = context(); + await expect(loadJob(ctx, 'nope')).rejects.toThrow(/no such job "nope"/); + }); + + it("claims this process's pid in the state file, overwriting the launcher's", async () => { + const ctx = context(); + const seeded = jobState(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, seeded); + + const loaded = await loadJob(ctx, seeded.id); + + expect(loaded).toBeDefined(); + const onDisk = await readJobState(ctx.fs, ctx.paths.jobsDir, seeded.id); + expect(onDisk?.pid).toBe(process.pid); + // The pre-claim pid was dead, so a liveness-corrected read (getJob's + // withLiveness) of the seed would already read "failed". The claim runs + // before the reporter is built specifically so the reporter's own + // `initial` -- and so the file, once report()/finish() write again -- + // never carries that phantom failure. See loadJob's own comment. + expect(onDisk?.state).toBe('running'); + expect(onDisk?.error).toBeNull(); + }); + + it('leaves every other field untouched', async () => { + const ctx = context(); + const seeded = jobState(); + await writeJobState(ctx.fs, ctx.paths.jobsDir, seeded); + + await loadJob(ctx, seeded.id); + + const onDisk = await readJobState(ctx.fs, ctx.paths.jobsDir, seeded.id); + expect(onDisk).toEqual({ ...seeded, pid: process.pid }); + }); + + it('is idempotent when this process already owns the pid', async () => { + const ctx = context(); + const seeded = jobState({ pid: process.pid }); + await writeJobState(ctx.fs, ctx.paths.jobsDir, seeded); + + await loadJob(ctx, seeded.id); + + const onDisk = await readJobState(ctx.fs, ctx.paths.jobsDir, seeded.id); + expect(onDisk).toEqual(seeded); + }); +}); diff --git a/apps/cli/src/jobs/loadJob.ts b/apps/cli/src/jobs/loadJob.ts new file mode 100644 index 0000000..2baa744 --- /dev/null +++ b/apps/cli/src/jobs/loadJob.ts @@ -0,0 +1,66 @@ +import { UsageError } from '@ailoud/core'; +import type { CliContext } from '../wiring.js'; +import { JobLog } from './log.js'; +import { JobReporter } from './reporter.js'; +import { readJobState, writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +/** + * Loads the job named by `--job`, or undefined when the flag was not given. + * + * A missing id is a UsageError naming it rather than a silent no-op: the id + * came from whatever process started this one (a detached `--detach` child, + * or the MCP server), and a wrong or stale one means something upstream is + * confused, not that this run should quietly report nowhere. + * + * Shared by every command that accepts `--job`, rather than written per + * command: this pipeline was written twice before this (see + * summarizeRun.ts's own doc comment) and the copies drifted, and the same + * thing happened to the lock takeover in exclusiveLock.ts. A third copy of + * this ~20-line helper would be exactly that mistake again. + * + * Claims the pid before building the reporter, writing this process's own + * `process.pid` into the state file. `createJob` had to write *some* pid + * before this process existed to claim it, and for `--detach` that was the + * launcher's -- a process that exits the moment it has spawned this one, so + * without a correction `withLiveness` (store.ts) reads the file as a corpse + * and flips a job that is very much running to `failed`. `spawn.ts` already + * rewrites the pid to the child's right after spawning it, best-effort, but + * that write can fail, race, or simply not have landed yet by the time this + * runs -- and correctness must not depend on it landing. Read this raw with + * `readJobState` rather than through `getJob`'s `withLiveness`: this process + * IS the job, so its own liveness is not in question the way a poller's read + * of a stranger's pid is, and seeding the reporter's `initial` with a + * liveness-corrected `state: 'failed'` would be wrong -- nothing after the + * constructor rewrites `state` back to `'running'` on its own, so a + * progress report would climb `percent` while the file kept insisting the + * job had already died. + * + * The claim is idempotent: whichever of this write and spawn.ts's write + * lands last, both store this same process's pid, so they cannot disagree. + * + * This also covers a future MCP-spawned worker for free, with no change + * here or there: `--job` is only ever passed to the process actually doing + * the work, never to the one that started it, so claiming the pid on load + * is correct for any launcher, not just `--detach`. + */ +export async function loadJob( + context: CliContext, + id: string | undefined, +): Promise<{ readonly reporter: JobReporter; readonly log: JobLog } | undefined> { + if (id === undefined) return undefined; + const state = await readJobState(context.fs, context.paths.jobsDir, id); + if (state === null) { + throw new UsageError(`no such job "${id}".`); + } + const claimed: JobState = { ...state, pid: process.pid }; + await writeJobState(context.fs, context.paths.jobsDir, claimed); + const log = new JobLog(claimed.log); + const reporter = new JobReporter({ + fs: context.fs, + jobsDir: context.paths.jobsDir, + initial: claimed, + log, + }); + return { reporter, log }; +} diff --git a/apps/cli/src/jobs/lock.test.ts b/apps/cli/src/jobs/lock.test.ts new file mode 100644 index 0000000..ab15041 --- /dev/null +++ b/apps/cli/src/jobs/lock.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { jobLockHolder, jobLockPath, withJobLock } from './lock.js'; + +describe('withJobLock', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'ailoud-jobs-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('runs the body and releases the lock', async () => { + await expect(withJobLock(dir, async () => 'done')).resolves.toBe('done'); + await expect(jobLockHolder(dir)).resolves.toBeNull(); + }); + + it('refuses a second run while the first holds it, naming the holder', async () => { + await withJobLock(dir, async () => { + await expect(withJobLock(dir, async () => 'second')).rejects.toThrow( + /another ailoud job is already running/, + ); + }); + }); + + it('reports the holder while the body runs', async () => { + await withJobLock(dir, async () => { + const holder = await jobLockHolder(dir); + expect(holder?.pid).toBe(process.pid); + }); + }); + + it('releases the lock even when the body throws', async () => { + await expect(withJobLock(dir, () => Promise.reject(new Error('boom')))).rejects.toThrow('boom'); + await expect(jobLockHolder(dir)).resolves.toBeNull(); + }); + + it('keeps its lock file separate from provisioning', () => { + expect(jobLockPath(dir)).toContain('jobs.lock'); + }); +}); diff --git a/apps/cli/src/jobs/lock.ts b/apps/cli/src/jobs/lock.ts new file mode 100644 index 0000000..879be3d --- /dev/null +++ b/apps/cli/src/jobs/lock.ts @@ -0,0 +1,50 @@ +import { join } from 'node:path'; +import { readLockHolder, withExclusiveLock } from '../exclusiveLock.js'; +import type { LockHolder } from '../exclusiveLock.js'; + +export function jobLockPath(dataDir: string): string { + return join(dataDir, 'jobs.lock'); +} + +/** + * The message shown when a live job already holds the lock. + * + * Exported so a caller with `jobLockHolder`'s advisory answer in hand -- + * `--detach`, refusing up front before it creates a job or spawns anything -- + * describes that same holder the same way `withJobLock` does when it refuses + * for real. Written once rather than twice, so the two never say it + * differently. + */ +export function jobBusyMessage(holder: LockHolder): string { + return ( + `another ailoud job is already running (pid ${holder.pid}, started ${holder.startedAt}). ` + + 'Wait for it to finish, or stop it, then try again.' + ); +} + +/** + * One background job at a time, per library. + * + * Not a throughput choice. whisper takes every core it is given -- measured + * at 4 threads on this machine -- so two concurrent jobs make both slower + * and neither finishes sooner. + * + * Refused rather than queued, like provisioning: a queued job reporting 0% + * for an hour is indistinguishable from a hung one. + */ +export function withJobLock(dataDir: string, body: () => Promise): Promise { + return withExclusiveLock( + jobLockPath(dataDir), + { + busy: jobBusyMessage, + stealing: 'another ailoud job is taking over a stale lock right now. Try again.', + raced: 'another ailoud job took the lock at the same moment. Try again.', + }, + body, + ); +} + +/** Who holds the job lock, for a caller that wants to refuse up front. Advisory. */ +export function jobLockHolder(dataDir: string): Promise { + return readLockHolder(jobLockPath(dataDir)); +} diff --git a/apps/cli/src/jobs/log.test.ts b/apps/cli/src/jobs/log.test.ts new file mode 100644 index 0000000..ad4bf6a --- /dev/null +++ b/apps/cli/src/jobs/log.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { JobLog } from './log.js'; + +describe('JobLog', () => { + it('appends lines in order', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-log-')); + try { + const path = join(dir, 'j.log'); + const log = new JobLog(path); + log.append('one'); + log.append('two'); + await log.flush(); + await expect(readFile(path, 'utf8')).resolves.toBe('one\ntwo\n'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('swallows a write failure instead of raising it', async () => { + // A path inside a file, which cannot be a directory: every write fails. + const dir = await mkdtemp(join(tmpdir(), 'ailoud-log-')); + try { + const blocker = join(dir, 'blocker'); + await writeFile(blocker, 'x'); + const log = new JobLog(join(blocker, 'nested.log')); + log.append('one'); + await expect(log.flush()).resolves.toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/jobs/log.ts b/apps/cli/src/jobs/log.ts new file mode 100644 index 0000000..2088643 --- /dev/null +++ b/apps/cli/src/jobs/log.ts @@ -0,0 +1,68 @@ +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** + * The job's append-only trail: stage transitions, warnings, and the reason a + * job failed. It does NOT carry the engine's own stderr -- nothing in this + * codebase plumbs that through yet, and `error` on the state document already + * carries the failure message a caller would want. + * + * Appends are queued rather than awaited by the caller, because the caller + * is a progress sink in the middle of a transcription and must not be given + * anything to await or to catch. `flush` exists for the end of the run and + * for tests. + * + * Takes only a path, not an `Fs`. `Fs` has no append operation, and adding + * one to the port for a log file would widen an interface five + * implementations share for the sake of one caller. This uses node:fs + * directly, which `apps/cli` is allowed to do -- the no-I/O rule binds + * `packages/core` only. + */ +export class JobLog { + private queue: Promise = Promise.resolve(); + private dirReady: Promise | undefined; + + public constructor(private readonly path: string) {} + + /** + * Creates the log's directory, once per instance. + * + * mkdir is idempotent but not free, and whisper alone writes on the order + * of 104 stderr lines per run -- re-issuing a recursive mkdir before every + * one of them is that many redundant syscalls for a directory that only + * ever needs creating once. The promise is memoised, including a + * rejection: if the directory genuinely cannot be created, later appends + * find that out from the cached rejection rather than retrying the same + * doomed mkdir. That rejection is always awaited from inside `append`'s + * own try, in the same call that creates it, so it can never surface as + * an unhandled rejection -- the same reasoning that governs `append` + * itself. + */ + private ensureDir(): Promise { + this.dirReady ??= mkdir(dirname(this.path), { recursive: true }).then(() => undefined); + return this.dirReady; + } + + /** + * Queues one line. Never throws, never returns anything to await. + * + * A log that cannot be written costs the log. It does not cost the + * transcription, and it does not get to reject somewhere nobody is + * listening -- hence the catch on the chain rather than on the caller. + */ + public append(line: string): void { + this.queue = this.queue + .then(async () => { + await this.ensureDir(); + await appendFile(this.path, `${line}\n`, 'utf8'); + }) + .catch(() => { + // See the doc comment. Deliberately empty. + }); + } + + /** Waits for every queued append. Resolves even when they all failed. */ + public async flush(): Promise { + await this.queue; + } +} diff --git a/apps/cli/src/jobs/reporter.test.ts b/apps/cli/src/jobs/reporter.test.ts new file mode 100644 index 0000000..82c0a56 --- /dev/null +++ b/apps/cli/src/jobs/reporter.test.ts @@ -0,0 +1,437 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { NodeFs } from '@ailoud/providers'; +import type { Fs, TempDir, TempFile } from '@ailoud/core'; +import { JobLog } from './log.js'; +import { JobReporter } from './reporter.js'; +import { jobStatePath, readJobState } from './state.js'; +import type { JobState } from './state.js'; + +/** Reads and parses the state file, for assertions that need to see which keys are truly absent. */ +async function readRawState(dir: string, id: string): Promise> { + const raw = await readFile(jobStatePath(dir, id), 'utf8'); + return JSON.parse(raw) as Record; +} + +function initial(dir: string): JobState { + return { + id: '01K4TESTTESTTESTTESTTESTTE', + kind: 'transcribe', + state: 'running', + percent: 0, + stage: 'starting', + pid: process.pid, + startedAt: '2026-09-07T08:00:00.000Z', + finishedAt: null, + recordings: { total: 2, done: 0 }, + declared: { speakers: 3, languages: ['ru'] }, + log: join(dir, 'j.log'), + result: null, + error: null, + }; +} + +async function withDir(body: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-rep-')); + try { + return await body(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** + * Lets an already-queued async write actually run, without forcing a new + * one the way `reporter.flush()` would. `report()` only *starts* a write on + * the reporter's internal promise chain -- the chain's `.then()` callback + * does not run until the current synchronous stretch of the test yields to + * the event loop, and the underlying filesystem I/O settles on a later + * macrotask, not merely the next microtask. A real setTimeout gives both a + * chance to complete before the next assertion reads `counting.writes`. + */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +/** + * Delegates every call to a real NodeFs and counts writeTextFile calls. + * + * The brief's own sketch built this by spreading a NodeFs instance + * (`{ ...fs, writeTextFile: ... }`). That does not work: NodeFs's methods + * live on its prototype, not as own enumerable properties, so a spread of an + * instance copies nothing and the resulting object is missing exists, + * ensureDir and the rest -- writeJobState calls fs.ensureDir first and + * throws on the spread object. A small class implementing Fs and delegating + * to a real NodeFs keeps every other method intact. + */ +class CountingFs implements Fs { + public writes = 0; + private readonly inner = new NodeFs(); + + exists(path: string): Promise { + return this.inner.exists(path); + } + ensureDir(path: string): Promise { + return this.inner.ensureDir(path); + } + sha256(path: string): Promise { + return this.inner.sha256(path); + } + copyFile(source: string, destination: string): Promise { + return this.inner.copyFile(source, destination); + } + listFiles(directory: string): Promise { + return this.inner.listFiles(directory); + } + removeFile(path: string): Promise { + return this.inner.removeFile(path); + } + isDirectory(path: string): Promise { + return this.inner.isDirectory(path); + } + tempFile(extension: string): Promise { + return this.inner.tempFile(extension); + } + tempDir(): Promise { + return this.inner.tempDir(); + } + async writeTextFile(path: string, content: string): Promise { + this.writes += 1; + await this.inner.writeTextFile(path, content); + } + readTextFile(path: string): Promise { + return this.inner.readTextFile(path); + } + rename(from: string, to: string): Promise { + return this.inner.rename(from, to); + } +} + +describe('JobReporter', () => { + it('writes the percentage it was told', async () => { + await withDir(async (dir) => { + let clock = 0; + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 100, + }); + reporter.report({ stage: 'transcribing', fraction: 0.46 }); + clock = 1000; + reporter.report({ stage: 'transcribing', fraction: 0.47 }); + await reporter.finish({ ok: true }); + const state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.percent).toBe(100); + expect(state?.state).toBe('done'); + expect(state?.result).toEqual({ ok: true }); + }); + }); + + it('clears a stale error carried in from initial once the job finishes', async () => { + await withDir(async (dir) => { + // A job whose launcher had already exited by the time something read + // it back reads as dead under withLiveness -- error message and all -- + // even though the work genuinely finished. `initial` here stands in + // for that: this pins finish() clearing it rather than letting a + // 'done' job carry a leftover error that contradicts its own state. + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: { ...initial(dir), error: "the job's process (pid 999999) is no longer running" }, + log: new JobLog(join(dir, 'j.log')), + }); + await reporter.finish({ ok: true }); + const state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.state).toBe('done'); + expect(state?.error).toBeNull(); + }); + }); + + it('never lowers the percentage', async () => { + await withDir(async (dir) => { + let clock = 0; + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 0, + }); + reporter.report({ stage: 'a', fraction: 0.6 }); + clock = 10_000; + reporter.report({ stage: 'b', fraction: 0.2 }); + clock = 20_000; + await reporter.flush(); + const state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.percent).toBe(60); + expect(state?.stage).toBe('b'); + }); + }); + + it('throttles writes, resumes once the window elapses, and never throttles the terminal write', async () => { + await withDir(async (dir) => { + const counting = new CountingFs(); + let clock = 0; + const reporter = new JobReporter({ + fs: counting, + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 2000, + }); + // With the clock frozen, only the very first report can write: this + // alone would also pass an implementation that stopped writing + // permanently after call one, so it does not by itself prove + // throttling -- the clock advance below does. settle() (not flush()) + // is used to observe the count here: flush() always forces a write of + // its own, which would make the count go up regardless of whether the + // throttle window had actually elapsed, and so would not prove + // resumption at all. + for (let i = 1; i <= 25; i += 1) reporter.report({ stage: 's', fraction: i / 100 }); + await settle(); + const beforeWindow = counting.writes; + expect(beforeWindow).toBeLessThan(5); + + // A full throttle window later, the next report must write again -- + // this is what distinguishes "throttled" from "never writes again". + clock = 2000; + reporter.report({ stage: 's', fraction: 0.26 }); + await settle(); + expect(counting.writes).toBeGreaterThan(beforeWindow); + + for (let i = 27; i <= 50; i += 1) reporter.report({ stage: 's', fraction: i / 100 }); + await reporter.flush(); + const throttled = counting.writes; + await reporter.finish(null); + expect(throttled).toBeLessThan(5); + expect(counting.writes).toBeGreaterThan(throttled); + }); + }); + + it('omits the ETA below five per cent and offers one above it', async () => { + await withDir(async (dir) => { + let clock = 0; + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 0, + }); + reporter.report({ stage: 's', fraction: 0.02 }); + clock = 5000; + await reporter.flush(); + expect((await readJobState(new NodeFs(), dir, initial(dir).id))?.etaSeconds).toBeUndefined(); + + reporter.report({ stage: 's', fraction: 0.5 }); + clock = 60_000; + await reporter.flush(); + const eta = (await readJobState(new NodeFs(), dir, initial(dir).id))?.etaSeconds; + expect(eta).toBeGreaterThan(0); + }); + }); + + it('drops the ETA once the job finishes, rather than leaving a stale one', async () => { + await withDir(async (dir) => { + let clock = 0; + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 100, + }); + // Same shape as "writes the percentage it was told": the second + // report computes a real ETA before finish() is ever called. + reporter.report({ stage: 'transcribing', fraction: 0.46 }); + clock = 1000; + reporter.report({ stage: 'transcribing', fraction: 0.47 }); + await reporter.finish({ ok: true }); + const state = await readRawState(dir, initial(dir).id); + // Checks the parsed JSON document itself, not a typed read: a key + // that was written as `etaSeconds: undefined` would still read as + // `undefined` through readJobState, masking the bug this pins. + expect('etaSeconds' in state).toBe(false); + }); + }); + + it('drops the ETA when the job fails, rather than leaving a stale one', async () => { + await withDir(async (dir) => { + let clock = 0; + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 100, + }); + reporter.report({ stage: 'transcribing', fraction: 0.46 }); + clock = 1000; + reporter.report({ stage: 'transcribing', fraction: 0.47 }); + await reporter.fail('whisper failed: exit 1'); + const state = await readRawState(dir, initial(dir).id); + expect('etaSeconds' in state).toBe(false); + }); + }); + + it('clears an earlier ETA once a plain report reaches completion', async () => { + await withDir(async (dir) => { + let clock = 0; + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => clock, + throttleMs: 0, + }); + reporter.report({ stage: 'transcribing', fraction: 0.5 }); + clock = 60_000; + reporter.report({ stage: 'transcribing', fraction: 0.51 }); + await reporter.flush(); + const withEta = await readRawState(dir, initial(dir).id); + expect('etaSeconds' in withEta).toBe(true); + + // fraction 1 with no finish() call at all -- eta() itself must clear + // the key, not just finish()/fail(). + reporter.report({ stage: 'transcribing', fraction: 1 }); + await reporter.flush(); + const atCompletion = await readRawState(dir, initial(dir).id); + expect('etaSeconds' in atCompletion).toBe(false); + }); + }); + + it('logs a stage or percentage change once, and repeats not at all', async () => { + await withDir(async (dir) => { + const logPath = join(dir, 'j.log'); + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(logPath), + now: () => 0, + throttleMs: 0, + }); + // First call is a genuine change from the initial "starting"/0%: logs. + reporter.report({ stage: 'transcribing', fraction: 0.3 }); + // Two exact repeats: whisper-style flooding. Neither should log -- + // the bug this pins compared against the constructor's fixed initial + // stage forever, so every one of these would have logged too. + reporter.report({ stage: 'transcribing', fraction: 0.3 }); + reporter.report({ stage: 'transcribing', fraction: 0.3 }); + // A real stage change: logs again. + reporter.report({ stage: 'diarizing', fraction: 0.3 }); + await reporter.flush(); + const lines = (await readFile(logPath, 'utf8')).trim().split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('transcribing 30%'); + expect(lines[1]).toContain('diarizing 30%'); + }); + }); + + it('keeps a stage with no fraction, without touching the percentage', async () => { + await withDir(async (dir) => { + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + now: () => 0, + throttleMs: 0, + }); + reporter.report({ stage: 'x', fraction: 0.3 }); + reporter.report({ stage: 'diarizing' }); + await reporter.flush(); + const state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.stage).toBe('diarizing'); + expect(state?.percent).toBe(30); + }); + }); + + it('advance() records how many recordings in the batch are done', async () => { + await withDir(async (dir) => { + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + throttleMs: 0, + }); + reporter.advance(1); + await reporter.flush(); + let state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.recordings).toEqual({ total: 2, done: 1 }); + + reporter.advance(2); + await reporter.flush(); + state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.recordings).toEqual({ total: 2, done: 2 }); + }); + }); + + it('fail() is a no-op once the job already reached a terminal state', async () => { + await withDir(async (dir) => { + // I2: the try/catch that calls fail() now wraps withJobLock itself, so + // a throw from the lock's release path -- after body() already + // succeeded and finish() already ran -- must not turn a done job back + // into a failed one. + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + }); + await reporter.finish({ ok: true }); + await reporter.fail('lock release blew up'); + const state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.state).toBe('done'); + expect(state?.error).toBeNull(); + expect(state?.result).toEqual({ ok: true }); + }); + }); + + it('records a failure as a message, with no stack', async () => { + await withDir(async (dir) => { + const reporter = new JobReporter({ + fs: new NodeFs(), + jobsDir: dir, + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + }); + await reporter.fail('whisper failed: exit 1'); + const state = await readJobState(new NodeFs(), dir, initial(dir).id); + expect(state?.state).toBe('failed'); + expect(state?.error).toBe('whisper failed: exit 1'); + expect(state?.finishedAt).not.toBeNull(); + }); + }); + + it('reports into an unwritable directory without throwing', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + const blocker = join(dir, 'blocker'); + await fs.writeTextFile(blocker, 'x'); + const reporter = new JobReporter({ + fs, + // A path inside a file: every write and every ensureDir fails. + jobsDir: join(blocker, 'jobs'), + initial: initial(dir), + log: new JobLog(join(dir, 'j.log')), + throttleMs: 0, + }); + expect(() => reporter.report({ stage: 's', fraction: 0.5 })).not.toThrow(); + await expect(reporter.flush()).resolves.toBeUndefined(); + await expect(reporter.finish(null)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/cli/src/jobs/reporter.ts b/apps/cli/src/jobs/reporter.ts new file mode 100644 index 0000000..ebda74e --- /dev/null +++ b/apps/cli/src/jobs/reporter.ts @@ -0,0 +1,207 @@ +import { clampMonotonic } from '@ailoud/core'; +import type { Fs, OnProgress, ProgressEvent } from '@ailoud/core'; +import type { JobLog } from './log.js'; +import { writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +/** How often the state file is rewritten while work is in flight. */ +const DEFAULT_THROTTLE_MS = 2000; + +/** Below this, an ETA is noise presented as a number. */ +const ETA_FLOOR = 0.05; + +export interface JobReporterOptions { + readonly fs: Fs; + readonly jobsDir: string; + readonly initial: JobState; + readonly log: JobLog; + readonly now?: () => number; + readonly throttleMs?: number; +} + +/** + * Turns the pipeline's progress events into the job's state file. + * + * Three properties, in the order they matter: + * + * 1. **It cannot throw.** `report` is called from inside a transcription. It + * updates memory, may start a write, and swallows everything -- including + * the rejection of a write nobody is awaiting, which would otherwise + * surface as an unhandled rejection and take the process down. + * 2. **The percentage never falls.** Through clampMonotonic, so a stage that + * recomputes its estimate cannot walk the bar backwards. + * 3. **Writes are throttled, terminal writes are not.** whisper emits + * hundreds of progress lines an hour and polls arrive minutes apart, so + * an unthrottled writer rewrites the file tens of thousands of times for + * no reader. `finish` and `fail` always write. + */ +export class JobReporter { + private current: JobState; + private fraction: number; + private lastWriteAt = Number.NEGATIVE_INFINITY; + private queue: Promise = Promise.resolve(); + private readonly now: () => number; + private readonly throttleMs: number; + private readonly startedAtMs: number; + // Baseline for the log-flooding guard in report(): compared against the + // PREVIOUS logged event, not the constructor's initial stage, so that + // once the stage has moved away from "starting" the comparison does not + // stay permanently true. See report()'s comment. + private lastLoggedStage: string; + private lastLoggedPercent: number; + + public constructor(private readonly options: JobReporterOptions) { + this.current = options.initial; + this.fraction = Math.min(1, Math.max(0, options.initial.percent / 100)); + this.now = options.now ?? (() => Date.now()); + this.throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; + this.startedAtMs = this.now(); + this.lastLoggedStage = options.initial.stage; + this.lastLoggedPercent = options.initial.percent; + } + + /** A sink to hand straight to a pipeline's `onProgress`. */ + public get onProgress(): OnProgress { + return (event) => { + this.report(event); + }; + } + + public report(event: ProgressEvent): void { + const next = + event.fraction === undefined ? this.fraction : clampMonotonic(this.fraction, event.fraction); + this.fraction = next; + const percent = Math.floor(next * 100); + const etaSeconds = this.eta(next); + // Drop any stale etaSeconds from the previous state before deciding + // whether the new one applies -- a spread of {} (no ETA this time) + // would otherwise leave the old value sitting in the document. See the + // class comment and eta()'s own comment. + const { etaSeconds: _previousEta, ...withoutEta } = this.current; + this.current = { + ...withoutEta, + stage: event.stage, + percent, + ...(etaSeconds === undefined ? {} : { etaSeconds }), + }; + // Compared against the previous LOGGED event, not the constructor's + // fixed initial stage: whisper emits hundreds of lines an hour, and a + // comparison against a value that never updates would log every one of + // them once the stage had moved even once. Only a genuine stage change + // or a genuine percentage change is worth a line. + if (event.stage !== this.lastLoggedStage || percent !== this.lastLoggedPercent) { + this.lastLoggedStage = event.stage; + this.lastLoggedPercent = percent; + this.options.log.append(`${new Date(this.now()).toISOString()} ${event.stage} ${percent}%`); + } + if (this.now() - this.lastWriteAt >= this.throttleMs) this.enqueueWrite(); + } + + /** How many recordings of the batch are finished. Cosmetic, and cheap. */ + public advance(recordingsDone: number): void { + this.current = { + ...this.current, + recordings: { ...this.current.recordings, done: recordingsDone }, + }; + this.enqueueWrite(); + } + + public async finish(result: unknown): Promise { + // A terminal state has no remaining time by definition -- drop any ETA + // rather than let the last one report() computed ride through into the + // persisted 'done' document. + const { etaSeconds: _previousEta, ...withoutEta } = this.current; + this.current = { + ...withoutEta, + state: 'done', + percent: 100, + finishedAt: new Date(this.now()).toISOString(), + result, + // A done job carrying a leftover error would say two contradictory + // things about itself. `initial` can already have one set -- a + // `--job` id created by a process that has since exited (a detached + // launcher, always) reads as a dead job under withLiveness the moment + // anyone checks on it, error message and all, even though the work + // genuinely finished. Explicit rather than relying on the object this + // spreads from never having one: that would be true today only by + // accident of which callers exist. + error: null, + }; + await this.writeNow(); + } + + public async fail(message: string): Promise { + // A no-op once the job already reached a terminal state. The try/catch + // that calls fail() now wraps withJobLock's own release path (see I2 in + // the review that added this guard): a lock's cleanup can throw AFTER + // body() has already run to completion and finish() has already written + // 'done'. Without this guard, that throw would still reach here and turn + // a job that genuinely finished into one reported as failed. + if (this.current.state !== 'running') return; + // See finish(): a failed job has no remaining time either. + const { etaSeconds: _previousEta, ...withoutEta } = this.current; + this.current = { + ...withoutEta, + state: 'failed', + finishedAt: new Date(this.now()).toISOString(), + error: message, + }; + this.options.log.append(`${new Date(this.now()).toISOString()} failed: ${message}`); + await this.writeNow(); + } + + /** Waits for whatever writes are queued. Resolves even when they failed. */ + public async flush(): Promise { + this.enqueueWrite(); + await this.queue; + await this.options.log.flush(); + } + + /** + * Remaining seconds, or undefined. + * + * Undefined below ETA_FLOOR: at 2% the elapsed time says almost nothing + * about the total, and a number that will be wrong by an order of + * magnitude is worse than no number. Returning a plain `number | undefined` + * rather than a spreadable `{ etaSeconds?: number }` is deliberate -- the + * caller must decide explicitly whether the key is present or absent in + * the next state, not rely on spreading `{}` to leave an old value alone. + */ + private eta(fraction: number): number | undefined { + if (fraction < ETA_FLOOR || fraction >= 1) return undefined; + const elapsed = this.now() - this.startedAtMs; + if (elapsed <= 0) return undefined; + const remaining = (elapsed / fraction) * (1 - fraction); + if (!Number.isFinite(remaining) || remaining < 0) return undefined; + return Math.round(remaining / 1000); + } + + /** + * Starts a write nobody awaits. + * + * The `.catch` sits on the chain itself -- `this.queue = this.queue.then(...).catch(...)` + * -- rather than being attached separately to the promise this method + * hands back to a caller (it hands back nothing). That placement is what + * keeps a failed write from becoming an unhandled rejection: `this.queue` + * always settles fulfilled, because every rejection that could reach it is + * caught before it is assigned back to the field. A later `.then` chained + * from `flush` or `writeNow` therefore never observes a rejected promise. + */ + private enqueueWrite(): void { + this.lastWriteAt = this.now(); + const snapshot = this.current; + this.queue = this.queue + .then(() => writeJobState(this.options.fs, this.options.jobsDir, snapshot)) + .catch(() => { + // A state file that cannot be written costs the percentage. It does + // not cost the transcription, and it must not reject into a caller + // that is not awaiting anything. See the class comment. + }); + } + + private async writeNow(): Promise { + this.enqueueWrite(); + await this.queue; + await this.options.log.flush(); + } +} diff --git a/apps/cli/src/jobs/spawn.test.ts b/apps/cli/src/jobs/spawn.test.ts new file mode 100644 index 0000000..d231cc6 --- /dev/null +++ b/apps/cli/src/jobs/spawn.test.ts @@ -0,0 +1,175 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MemFs } from '@ailoud/core/testing'; +import { buildDetachedArgs, cliEntryPath, spawnDetachedJob } from './spawn.js'; +import { readJobState, writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +describe('cliEntryPath', () => { + it('points at a javascript file that exists in the built tree', () => { + expect(cliEntryPath()).toMatch(/\.js$/); + }); +}); + +describe('buildDetachedArgs', () => { + it('puts the entry first and the job id last', () => { + const args = buildDetachedArgs(['audio', 'transcribe', 'REC1', '--lang', 'ru'], 'JOB1'); + expect(args[0]).toBe(cliEntryPath()); + expect(args.slice(-2)).toEqual(['--job', 'JOB1']); + }); + + it('passes every argument through as its own array element', () => { + // Never a shell string: paths reaching here come from user input, and a + // shell would interpret them. AGENTS.md, Security Notes. + const args = buildDetachedArgs(['audio', 'transcribe', '/in/a b; rm -rf x.wav'], 'JOB1'); + expect(args).toContain('/in/a b; rm -rf x.wav'); + }); +}); + +// child_process.spawn is swapped for a fake that never touches a real +// process: this suite is about the pid rewrite around it, not about +// whether `node` itself can be launched in CI. +vi.mock('node:child_process', () => ({ spawn: vi.fn() })); + +function fakeChild( + pid: number | undefined, +): EventEmitter & { pid: number | undefined; unref: ReturnType } { + const child = new EventEmitter() as EventEmitter & { + pid: number | undefined; + unref: ReturnType; + }; + child.pid = pid; + child.unref = vi.fn(); + return child; +} + +function job(partial: Partial = {}): JobState { + return { + id: '01SPAWNTESTTESTTESTTESTTESTT', + kind: 'transcribe', + state: 'running', + percent: 0, + stage: 'starting', + pid: process.pid, // the launcher's own pid, as createJob writes it + startedAt: '2026-09-07T08:00:00.000Z', + finishedAt: null, + recordings: { total: 1, done: 0 }, + declared: null, + log: '/d/jobs/01SPAWNTESTTESTTESTTESTTESTT.log', + result: null, + error: null, + ...partial, + }; +} + +describe('spawnDetachedJob', () => { + afterEach(async () => { + const { spawn } = await import('node:child_process'); + vi.mocked(spawn).mockReset(); + }); + + it("rewrites the job's pid to the child's, not the launcher's", async () => { + const { spawn } = await import('node:child_process'); + vi.mocked(spawn).mockReturnValue(fakeChild(4242) as unknown as ReturnType); + const fs = new MemFs(); + const jobsDir = '/d/jobs'; + const initial = job(); + await writeJobState(fs, jobsDir, initial); + + await spawnDetachedJob({ fs, jobsDir }, ['transcribe'], initial); + + const state = await readJobState(fs, jobsDir, initial.id); + expect(state?.pid).toBe(4242); + // Everything else is carried through unchanged. + expect(state).toEqual({ ...initial, pid: 4242 }); + }); + + it('spawns an argument array, never a shell string', async () => { + const { spawn } = await import('node:child_process'); + const mock = vi + .mocked(spawn) + .mockReturnValue(fakeChild(1) as unknown as ReturnType); + const fs = new MemFs(); + const jobsDir = '/d/jobs'; + const initial = job(); + await writeJobState(fs, jobsDir, initial); + + await spawnDetachedJob({ fs, jobsDir }, ['transcribe', 'REC1'], initial); + + expect(mock).toHaveBeenCalledTimes(1); + const [command, args, options] = mock.mock.calls[0]!; + expect(command).toBe(process.execPath); + expect(Array.isArray(args)).toBe(true); + expect(options).toMatchObject({ detached: true, stdio: 'ignore', shell: false }); + }); + + it('leaves the state alone when the child never got a pid', async () => { + const { spawn } = await import('node:child_process'); + vi.mocked(spawn).mockReturnValue(fakeChild(undefined) as unknown as ReturnType); + const fs = new MemFs(); + const jobsDir = '/d/jobs'; + const initial = job(); + await writeJobState(fs, jobsDir, initial); + + await spawnDetachedJob({ fs, jobsDir }, ['transcribe'], initial); + + const state = await readJobState(fs, jobsDir, initial.id); + expect(state).toEqual(initial); + }); + + it("does not walk percent and stage back to zero when the child's own progress lands first", async () => { + // The `job` snapshot spawnDetachedJob is called with is stale the moment + // it is created -- percent: 0, stage: 'starting'. If the child's own pid + // claim and its first report() land on disk before this correction runs, + // a naive `{ ...job, pid, error: null }` write would spread that stale + // snapshot back over real progress. The correction must touch only pid + // and error. + const { spawn } = await import('node:child_process'); + vi.mocked(spawn).mockReturnValue(fakeChild(4242) as unknown as ReturnType); + const fs = new MemFs(); + const jobsDir = '/d/jobs'; + const initial = job(); + // The child has already claimed its own pid and reported real progress + // by the time the launcher's correction gets a chance to run. + const advanced: JobState = { ...initial, pid: 4242, percent: 47, stage: 'transcribing' }; + await writeJobState(fs, jobsDir, advanced); + + await spawnDetachedJob({ fs, jobsDir }, ['transcribe'], initial); + + const state = await readJobState(fs, jobsDir, initial.id); + expect(state).toEqual(advanced); + }); + + it('marks the job failed, without throwing, when spawn itself reports an error', async () => { + // An asynchronous spawn failure (EMFILE, a permissions problem, resource + // exhaustion) arrives as an 'error' event on the child, not as a thrown + // exception -- and it can arrive after spawnDetachedJob has already + // returned. A missing listener would take the launcher down with an + // uncaught exception; this asserts spawnDetachedJob itself never throws + // and that the failure is recorded on the job instead. + const { spawn } = await import('node:child_process'); + const child = fakeChild(4242); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const fs = new MemFs(); + const jobsDir = '/d/jobs'; + const initial = job(); + await writeJobState(fs, jobsDir, initial); + + await expect( + spawnDetachedJob({ fs, jobsDir }, ['transcribe'], initial), + ).resolves.toBeUndefined(); + + const error = Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }); + child.emit('error', error); + + await vi.waitFor(async () => { + const state = await readJobState(fs, jobsDir, initial.id); + expect(state?.state).toBe('failed'); + }); + + const state = await readJobState(fs, jobsDir, initial.id); + expect(state?.state).toBe('failed'); + expect(state?.error).toContain('EMFILE'); + expect(state?.finishedAt).not.toBeNull(); + }); +}); diff --git a/apps/cli/src/jobs/spawn.ts b/apps/cli/src/jobs/spawn.ts new file mode 100644 index 0000000..185a192 --- /dev/null +++ b/apps/cli/src/jobs/spawn.ts @@ -0,0 +1,164 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { Fs } from '@ailoud/core'; +import { readJobState, writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +/** + * ailoud's own entry module, resolved from this file rather than from + * `process.argv`. + * + * `argv[1]` is whatever launched this process, which under the MCP server is + * the same binary but under a test runner is not. Deriving it from + * `import.meta.url` means the detached child is always the build that spawned + * it -- never a different install that happens to be first on PATH. + */ +export function cliEntryPath(): string { + return fileURLToPath(new URL('../bin/ailoud.js', import.meta.url)); +} + +export function buildDetachedArgs(commandArgs: readonly string[], jobId: string): string[] { + return [cliEntryPath(), ...commandArgs, '--job', jobId]; +} + +/** + * Best-effort: overwrites only `pid` on whatever the job's state file + * actually holds at the moment this runs, not on the `job` snapshot + * `createJob` handed back. + * + * That snapshot is stale by construction -- it is a picture of `percent: 0, + * stage: 'starting'` from before the child had done anything. The child + * claims its own pid on load too (see loadJob.ts) and starts reporting real + * progress immediately; if that lands before this correction does, spreading + * the stale `job` over the current file (`{ ...job, pid }`) would silently + * walk `percent` and `stage` back to zero under real progress already + * written. Reading the current state first and touching only the field this + * correction is actually about avoids that regression entirely, whichever of + * the two writers landed last. + * + * Does NOT touch `error`. An earlier version also wrote `error: null` here, + * on the theory that a stale pid could make `withLiveness` misreport a live + * job as failed. That is true, but clearing `error` unconditionally would + * just as readily erase a genuine failure the child had already written + * before this correction lands -- `finish()` and `fail()` already clear + * `error` on their own way to a terminal state (see JobReporter), which is + * the right place for that, not a pid correction that runs at most once, + * best-effort, right after spawn. + */ +async function correctPid( + deps: { readonly fs: Fs; readonly jobsDir: string }, + job: JobState, + pid: number, +): Promise { + try { + const current = (await readJobState(deps.fs, deps.jobsDir, job.id)) ?? job; + await writeJobState(deps.fs, deps.jobsDir, { ...current, pid }); + } catch { + // Best-effort, like every other write JobReporter makes: the child is + // already running, detached, whether or not this correction lands. + // Losing it costs a poller a stale pid and, transiently, a possible + // "failed" from withLiveness reading it -- but it does not cost the + // transcription, and it must never be confused with the spawn itself + // having failed. + } +} + +/** + * Best-effort: marks the job failed when the child never actually started. + * + * Same read-modify-write shape as correctPid, and the same reason: this + * fires from the 'error' handler below, which can race the child's own + * writes in principle, so it must not clobber real state with a stale + * snapshot either -- though in practice a child that never started has + * written nothing yet. + */ +async function markSpawnFailed( + deps: { readonly fs: Fs; readonly jobsDir: string }, + job: JobState, + message: string, +): Promise { + try { + const current = (await readJobState(deps.fs, deps.jobsDir, job.id)) ?? job; + await writeJobState(deps.fs, deps.jobsDir, { + ...current, + state: 'failed', + finishedAt: new Date().toISOString(), + error: message, + }); + } catch { + // The id this job was handed out under must always resolve, but a + // write that fails here does not get to escape and take the launcher + // down a second time -- see the 'error' handler's own comment. + } +} + +/** + * Starts the work in a process that outlives this one. + * + * `detached` plus `unref` is what lets an MCP server exit, or an agent's + * session end, while an hour of transcription carries on -- which is the + * whole reason this feature exists. + * + * `stdio: 'ignore'` is not a contradiction of the job log: the child is + * ailoud, and it writes its own log file from inside. Nothing needs to + * survive a pipe, so nothing is piped -- an inherited pipe with no reader is + * a way to wedge the child on a full buffer after an hour of work. + * + * `run()` is deliberately not used: it waits for the child and buffers its + * output, which is the opposite of what is wanted here. + * + * No timeout, unlike every other subprocess this project spawns (run.ts's + * `DEFAULT_TIMEOUT_MS`), and `runInteractive` is not the only other + * exception: this is the second. A detached job outlives its launcher by + * definition, so the launcher cannot hold a timer for it -- it is about to + * exit -- and inventing one it cannot enforce would be worse than none. The + * work inside the child is bounded anyway: whisper itself is spawned through + * `run()` with its own six-hour timeout (whisperCpp.ts). What a timeout here + * could catch that nothing else does is the child wedging on something + * outside a subprocess call entirely, and `withLiveness` (store.ts) cannot + * see that either -- it detects a dead pid, not a hung one. + * + * `job` is the record `createJob` just wrote, whose `pid` is *this* + * process's -- the launcher's, which is about to return and exit. That is + * not the process a poller should be watching: once this returns, `pid` + * would name a process that is already gone while the job is still very + * much running, and `withLiveness` (see store.ts) would read that as a + * corpse and flip the job to `failed`, error message and all, under the + * launcher's own feet -- including for the job's own child the instant it + * loads itself back with `--job` and starts reporting from that falsely + * `failed` initial state, since nothing between there and a terminal write + * corrects `state` on its own. Rewritten here, immediately, to the child's + * real pid before anything else can read the file: the whole reason + * `--detach` exists is for the id it hands back to be trustworthy from the + * moment it is printed, not eventually. + */ +export async function spawnDetachedJob( + deps: { readonly fs: Fs; readonly jobsDir: string }, + commandArgs: readonly string[], + job: JobState, +): Promise { + // An argument array, never a shell string. The paths in here came from + // user input. AGENTS.md, Security Notes. + const child = spawn(process.execPath, buildDetachedArgs(commandArgs, job.id), { + detached: true, + stdio: 'ignore', + shell: false, + }); + // Attached synchronously, right after spawn(): an asynchronous spawn + // failure -- EMFILE, a permissions problem, resource exhaustion, anything + // that is not a synchronous throw -- arrives as an 'error' event on the + // child, and with no listener Node turns that into an uncaught exception. + // That exception surfaces after this function has already returned and + // the launcher has already printed the job id, so without this the + // launcher would die with a stack trace instead of exiting cleanly. Marks + // the job failed instead, best-effort, and never rethrows -- an id handed + // out must always resolve. Same class of failure, same fix, as run.ts's + // 'error' handler. + child.on('error', (error) => { + void markSpawnFailed(deps, job, error instanceof Error ? error.message : String(error)); + }); + child.unref(); + if (child.pid !== undefined) { + await correctPid(deps, job, child.pid); + } +} diff --git a/apps/cli/src/jobs/state.test.ts b/apps/cli/src/jobs/state.test.ts new file mode 100644 index 0000000..7154352 --- /dev/null +++ b/apps/cli/src/jobs/state.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { NodeFs } from '@ailoud/providers'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { jobLogPath, jobStatePath, readJobState, writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +function state(partial: Partial = {}): JobState { + return { + id: '01K4TESTTESTTESTTESTTESTTE', + kind: 'transcribe', + state: 'running', + percent: 0, + stage: 'starting', + pid: process.pid, + startedAt: '2026-09-07T08:00:00.000Z', + finishedAt: null, + recordings: { total: 1, done: 0 }, + declared: { speakers: 3, languages: ['ru', 'en'] }, + log: '/tmp/x.log', + result: null, + error: null, + ...partial, + }; +} + +describe('job state', () => { + it('round-trips through the filesystem', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-state-')); + try { + const fs = new NodeFs(); + const written = state({ percent: 46, stage: 'transcribing' }); + await writeJobState(fs, dir, written); + await expect(readJobState(fs, dir, written.id)).resolves.toEqual(written); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns null for an id it has never seen', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-state-')); + try { + await expect(readJobState(new NodeFs(), dir, 'nosuchid')).resolves.toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns null for a truncated file rather than throwing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-state-')); + try { + const fs = new NodeFs(); + await fs.ensureDir(dir); + await fs.writeTextFile(jobStatePath(dir, 'halfwritten'), '{"id": "half'); + await expect(readJobState(fs, dir, 'halfwritten')).resolves.toBeNull(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('replaces the file atomically, so a reader never sees a partial document', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-state-')); + try { + const fs = new NodeFs(); + const id = state().id; + await writeJobState(fs, dir, state()); + // Hammer reads against writes. Every read must yield a whole document + // or nothing, never a parse error -- that is what the rename buys. + const writes = (async () => { + for (let i = 1; i <= 200; i += 1) await writeJobState(fs, dir, state({ percent: i % 100 })); + })(); + const reads = (async () => { + for (let i = 0; i < 200; i += 1) { + const read = await readJobState(fs, dir, id); + expect(read === null || read.id === id).toBe(true); + } + })(); + await Promise.all([writes, reads]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('two concurrent writes for the same id never produce a torn document', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-state-')); + try { + const fs = new NodeFs(); + const id = state().id; + const a = state({ percent: 10, stage: 'a' }); + const b = state({ percent: 90, stage: 'b' }); + // Started together and only THEN awaited: a fixed scratch name would + // have both writers target the identical ".json.writing" file, so + // their writeTextFile calls could interleave and whichever rename + // lands second would promote a half-merged document. The randomised + // scratch name gives each writer its own file, so this can only ever + // resolve to one writer's whole document, never a mixture. + const first = writeJobState(fs, dir, a); + const second = writeJobState(fs, dir, b); + await Promise.all([first, second]); + const read = await readJobState(fs, dir, id); + expect(read).not.toBeNull(); + expect([a, b]).toContainEqual(read); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('puts the log beside the state, under the same id', () => { + expect(jobLogPath('/jobs', 'abc')).toBe('/jobs/abc.log'); + expect(jobStatePath('/jobs', 'abc')).toBe('/jobs/abc.json'); + }); +}); diff --git a/apps/cli/src/jobs/state.ts b/apps/cli/src/jobs/state.ts new file mode 100644 index 0000000..2ed4c89 --- /dev/null +++ b/apps/cli/src/jobs/state.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import type { Fs } from '@ailoud/core'; + +export type JobKind = 'transcribe' | 'summarize'; +export type JobStateName = 'running' | 'done' | 'failed'; + +/** + * Everything a poller needs, and nothing more. + * + * Small on purpose. This document is what an agent reads every few minutes, + * so the detail -- stage transitions and warnings as the job runs -- goes to + * `log` and is fetched by path rather than inlined here. Same trade + * `get_transcript` makes with a transcript. The log does not carry the + * engine's own stderr; `error` below already carries the failure message. + */ +export interface JobState { + readonly id: string; + readonly kind: JobKind; + readonly state: JobStateName; + /** Integer 0..100, monotonic. See clampMonotonic in @ailoud/core. */ + readonly percent: number; + readonly stage: string; + /** Omitted below 5%, where it would be noise presented as a number. */ + readonly etaSeconds?: number; + /** Whose process this is, so a poller can tell "died" from "working". */ + readonly pid: number; + readonly startedAt: string; + readonly finishedAt: string | null; + readonly recordings: { readonly total: number; readonly done: number }; + /** What the caller declared before starting. Recorded, not acted on. */ + readonly declared: { + readonly speakers: number | 'unknown'; + readonly languages: readonly string[]; + } | null; + readonly log: string; + /** Set once, on success. Never the summary body -- see the design. */ + readonly result: unknown; + /** One message on failure. No stack: nobody polls for a stack. */ + readonly error: string | null; +} + +export function jobStatePath(jobsDir: string, id: string): string { + return join(jobsDir, `${id}.json`); +} + +export function jobLogPath(jobsDir: string, id: string): string { + return join(jobsDir, `${id}.log`); +} + +/** + * Replaces the state file atomically. + * + * Written beside the target and renamed over it, through `Fs.rename`, whose + * own doc comment exists for exactly this: "callers write a temporary file + * beside the real one and rename it over the top, so a reader never sees + * half a file". Without it a poll eventually parses half a document and an + * agent reports a crash that never happened. + */ +export async function writeJobState(fs: Fs, jobsDir: string, state: JobState): Promise { + await fs.ensureDir(jobsDir); + const target = jobStatePath(jobsDir, state.id); + // Randomised per call, same pattern as writeRegistry in projects.ts, so + // two writers for the same job id never share -- and corrupt -- one + // temporary file. + const scratch = `${target}.${randomUUID()}.writing`; + await fs.writeTextFile(scratch, `${JSON.stringify(state, null, 2)}\n`); + await fs.rename(scratch, target); +} + +/** + * Reads a state file, or null. + * + * Null covers three cases a caller cannot usefully distinguish: no such job, + * a file that is not readable, and a file that is not valid JSON. The last + * one is a job that died between creating the file and writing it, and it + * must not surface as a parse error about a file the user never heard of -- + * the same reasoning `readHolder` in exclusiveLock.ts gives. + */ +export async function readJobState(fs: Fs, jobsDir: string, id: string): Promise { + const path = jobStatePath(jobsDir, id); + try { + const parsed: unknown = JSON.parse(await fs.readTextFile(path)); + if (typeof parsed !== 'object' || parsed === null) return null; + // Safe: the shape on disk is only ever written by writeJobState above, + // so a parsed object is a JobState. If it is not -- a manually edited + // file, a future format change -- the caller is a poller that treats + // this as "still running" at worst, not code that trusts the fields + // for anything unattended. + return parsed as JobState; + } catch { + return null; + } +} diff --git a/apps/cli/src/jobs/store.test.ts b/apps/cli/src/jobs/store.test.ts new file mode 100644 index 0000000..8966988 --- /dev/null +++ b/apps/cli/src/jobs/store.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { NodeFs } from '@ailoud/providers'; +import { createJob, getJob, listJobs, pruneJobs, removeJob, withLiveness } from './store.js'; +import { writeJobState } from './state.js'; +import type { JobState } from './state.js'; + +const CLOCK = { nowIso: () => '2026-09-07T08:00:00.000Z' }; + +function ids(...values: string[]) { + let i = 0; + return { next: () => values[i++] ?? `extra-${i}` }; +} + +function state(partial: Partial): JobState { + return { + id: 'j1', + kind: 'transcribe', + state: 'done', + percent: 100, + stage: 'done', + pid: process.pid, + startedAt: '2026-09-07T08:00:00.000Z', + finishedAt: '2026-09-07T09:00:00.000Z', + recordings: { total: 1, done: 1 }, + declared: null, + log: '/tmp/j1.log', + result: null, + error: null, + ...partial, + }; +} + +async function withDir(body: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-store-')); + try { + return await body(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +describe('createJob', () => { + it('writes a running job the caller can read back immediately', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + const job = await createJob( + { fs, ids: ids('01K4A'), clock: CLOCK, jobsDir: dir }, + { kind: 'transcribe', recordings: 3, declared: { speakers: 4, languages: ['ru', 'en'] } }, + ); + expect(job.state).toBe('running'); + expect(job.percent).toBe(0); + expect(job.recordings).toEqual({ total: 3, done: 0 }); + // The whole point: the id is resolvable the instant it is handed out. + await expect(getJob(fs, dir, job.id)).resolves.toMatchObject({ id: job.id }); + }); + }); +}); + +describe('withLiveness', () => { + it('leaves a finished job alone', () => { + expect(withLiveness(state({ state: 'done' })).state).toBe('done'); + }); + + it('leaves a running job with a live pid alone', () => { + expect(withLiveness(state({ state: 'running', pid: process.pid })).state).toBe('running'); + }); + + it('turns a running job with a dead pid into a failure', () => { + // Pid 1 is alive, so a fake high pid is used. A running job whose + // process is gone must not be polled for an hour as though it were live. + const dead = withLiveness(state({ state: 'running', pid: 2_147_483_600 })); + expect(dead.state).toBe('failed'); + expect(dead.error).toContain('no longer running'); + }); +}); + +describe('listJobs', () => { + it('returns newest first', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + await writeJobState(fs, dir, state({ id: '01A' })); + await writeJobState(fs, dir, state({ id: '01C' })); + await writeJobState(fs, dir, state({ id: '01B' })); + const listed = await listJobs(fs, dir); + expect(listed.map((job) => job.id)).toEqual(['01C', '01B', '01A']); + }); + }); + + it('is empty rather than throwing when the directory has never existed', async () => { + await withDir(async (dir) => { + await expect(listJobs(new NodeFs(), join(dir, 'nope'))).resolves.toEqual([]); + }); + }); + + it('skips a file that is not a job state document', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + await writeJobState(fs, dir, state({ id: '01A' })); + await fs.writeTextFile(join(dir, '01A.log'), 'not json'); + await fs.writeTextFile(join(dir, 'junk.json'), '{ broken'); + expect((await listJobs(fs, dir)).map((job) => job.id)).toEqual(['01A']); + }); + }); + + it('ignores a scratch file left behind by a write that crashed mid-rename', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + await writeJobState(fs, dir, state({ id: '01A' })); + // writeJobState's own scratch-path shape: .json..writing. + // It contains ".json" but does not end in it, so a naive substring + // filter would try to parse this as a job state document. + await fs.writeTextFile( + join(dir, '01B.json.9b1f1c1e-0000-4000-8000-000000000000.writing'), + `${JSON.stringify(state({ id: '01B' }))}\n`, + ); + expect((await listJobs(fs, dir)).map((job) => job.id)).toEqual(['01A']); + }); + }); +}); + +describe('pruneJobs', () => { + it('keeps the newest finished jobs and drops the rest, with their logs', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + for (const id of ['01A', '01B', '01C', '01D']) { + await writeJobState(fs, dir, state({ id })); + await fs.writeTextFile(join(dir, `${id}.log`), 'x'); + } + await pruneJobs(fs, dir, 2); + expect((await listJobs(fs, dir)).map((job) => job.id)).toEqual(['01D', '01C']); + await expect(fs.exists(join(dir, '01A.log'))).resolves.toBe(false); + }); + }); + + it('never prunes a running job, however old', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + await writeJobState(fs, dir, state({ id: '01A', state: 'running', pid: process.pid })); + await writeJobState(fs, dir, state({ id: '01B' })); + await writeJobState(fs, dir, state({ id: '01C' })); + await pruneJobs(fs, dir, 1); + expect((await listJobs(fs, dir)).map((job) => job.id)).toContain('01A'); + }); + }); +}); + +describe('removeJob', () => { + it('removes the state and the log, and reports that it did', async () => { + await withDir(async (dir) => { + const fs = new NodeFs(); + await writeJobState(fs, dir, state({ id: '01A' })); + await fs.writeTextFile(join(dir, '01A.log'), 'x'); + await expect(removeJob(fs, dir, '01A')).resolves.toBe(true); + await expect(fs.exists(join(dir, '01A.json'))).resolves.toBe(false); + await expect(fs.exists(join(dir, '01A.log'))).resolves.toBe(false); + }); + }); + + it('reports false for an id it never had', async () => { + await withDir(async (dir) => { + await expect(removeJob(new NodeFs(), dir, 'nope')).resolves.toBe(false); + }); + }); +}); diff --git a/apps/cli/src/jobs/store.ts b/apps/cli/src/jobs/store.ts new file mode 100644 index 0000000..965d059 --- /dev/null +++ b/apps/cli/src/jobs/store.ts @@ -0,0 +1,172 @@ +import { basename } from 'node:path'; +import type { Clock, Fs, Ids } from '@ailoud/core'; +import { isRunning } from '../exclusiveLock.js'; +import { jobLogPath, jobStatePath, readJobState, writeJobState } from './state.js'; +import type { JobKind, JobState } from './state.js'; + +/** + * How many finished jobs `createJob` keeps around before pruning the rest. + * + * The spec's number: enough for an agent that lost track of a few ids across + * a session to still find them with `job_status`'s no-argument listing (which + * itself only ever shows the five most recent finished jobs), without the + * directory growing by two files per job forever. + */ +const RETAIN_FINISHED_JOBS = 20; + +/** + * Corrects `running` to `failed` when the owning process is gone. + * + * A detached job outlives the server that started it, which is the point -- + * but it means `state: "running"` in the file is a claim, not a fact. Without + * this, a job killed by a reboot or an OOM reads as "still working" forever + * and an agent polls a corpse. + * + * Applied on read rather than written into the file: the process that would + * have to write it is the one that died. + */ +export function withLiveness(state: JobState): JobState { + if (state.state !== 'running') return state; + if (isRunning(state.pid)) return state; + return { + ...state, + state: 'failed', + error: + state.error ?? + `the job's process (pid ${state.pid}) is no longer running; see ${state.log} for what it managed to do`, + }; +} + +/** + * Starts a job: writes its state file and hands back the state, running. + * + * The write is awaited rather than fired and forgotten. The whole contract + * of the id this returns is that a caller can hand it straight to an agent + * and the agent can resolve it immediately -- a race between "return the id" + * and "the file exists" would make that contract a coin flip. + */ +export async function createJob( + deps: { fs: Fs; ids: Ids; clock: Clock; jobsDir: string }, + input: { kind: JobKind; recordings: number; declared: JobState['declared'] }, +): Promise { + const { fs, ids, clock, jobsDir } = deps; + const id = ids.next(); + const state: JobState = { + id, + kind: input.kind, + state: 'running', + percent: 0, + stage: 'starting', + pid: process.pid, + startedAt: clock.nowIso(), + finishedAt: null, + recordings: { total: input.recordings, done: 0 }, + declared: input.declared, + log: jobLogPath(jobsDir, id), + result: null, + error: null, + }; + await writeJobState(fs, jobsDir, state); + // Retention happens here, on the one path both front ends (the CLI's + // --detach and the MCP server) funnel through -- see the spec's "On job + // creation, finished jobs beyond the N most recent have both files + // removed." Best-effort: a prune that cannot list or remove files costs + // disk space, never the job this call just created and already wrote. + try { + await pruneJobs(fs, jobsDir, RETAIN_FINISHED_JOBS); + } catch { + // See above -- pruning is housekeeping, not part of the contract this + // function's own doc comment describes. + } + return state; +} + +/** + * Every job, newest first, with liveness corrected. + * + * Only entries ending in `.json` are treated as job state documents. + * `writeJobState` writes through a scratch path of the form + * `.json..writing` before renaming it over the target, and a crash + * between those two steps leaves that scratch file behind with nothing to + * clean it up. `.json` merely appearing in the name is not enough -- every + * one of those scratch files contains it too -- so this checks the name + * ends there. + * + * An absent directory (no job has ever been created) yields `[]` rather than + * an ENOENT from `fs.listFiles`. + * + * Sorted by id, descending, rather than by a timestamp field: ids are ULIDs, + * which sort lexically by creation time, so the id itself is the sort key. + */ +export async function listJobs(fs: Fs, jobsDir: string): Promise { + if (!(await fs.exists(jobsDir))) return []; + const ids = (await fs.listFiles(jobsDir)) + .filter((path) => path.endsWith('.json')) + .map((path) => basename(path, '.json')); + const jobs: JobState[] = []; + for (const id of ids) { + const state = await readJobState(fs, jobsDir, id); + // Null covers a file that vanished, or failed to parse, between the + // directory listing above and the read -- see readJobState's own + // comment. Either way there is nothing to report for it. + if (state !== null) jobs.push(withLiveness(state)); + } + return jobs.sort((a, b) => (a.id > b.id ? -1 : a.id < b.id ? 1 : 0)); +} + +/** One job by id, with liveness corrected, or null if there is no such job. */ +export async function getJob(fs: Fs, jobsDir: string, id: string): Promise { + const state = await readJobState(fs, jobsDir, id); + return state === null ? null : withLiveness(state); +} + +/** + * Deletes the oldest finished jobs, keeping the `keep` newest plus every + * running job regardless of age. + * + * A running job is never pruned, however old: its state file is the only + * record of a process that may still be working, and `withLiveness` (applied + * by `listJobs`, which this reads from) is what tells a genuinely dead one + * from a live one -- so by the time a job reaches this filter as "running", + * it already survived that check. + */ +export async function pruneJobs(fs: Fs, jobsDir: string, keep: number): Promise { + const jobs = await listJobs(fs, jobsDir); // newest first + const finished = jobs.filter((job) => job.state !== 'running'); + const toRemove = finished.slice(keep); + for (const job of toRemove) await removeJob(fs, jobsDir, job.id); + await removeStaleScratchFiles(fs, jobsDir); +} + +/** + * Removes leftover `.json..writing` scratch files. + * + * `writeJobState` renames its scratch file over the target once the write + * completes (see its own doc comment); a crash between writing the scratch + * file and the rename leaves it behind, and nothing else in this module ever + * looks at it again. Swept from here, on job creation, rather than adding a + * second sweep nobody calls -- the same reasoning that put retention itself + * in `createJob`. + */ +async function removeStaleScratchFiles(fs: Fs, jobsDir: string): Promise { + if (!(await fs.exists(jobsDir))) return; + const scratch = (await fs.listFiles(jobsDir)).filter((path) => path.endsWith('.writing')); + for (const path of scratch) await fs.removeFile(path); +} + +/** + * Removes a job's state file and its log. + * + * `fs.removeFile` treats an absent file as success, so this cannot fail on + * a job with no log yet -- there is a window between `createJob` and the + * first `JobLog.append` where the log file does not exist at all. The + * return value reports whether the state file existed beforehand, so a + * caller can tell "removed" from "there was never such a job". + */ +export async function removeJob(fs: Fs, jobsDir: string, id: string): Promise { + const statePath = jobStatePath(jobsDir, id); + const existed = await fs.exists(statePath); + await fs.removeFile(statePath); + await fs.removeFile(jobLogPath(jobsDir, id)); + return existed; +} diff --git a/apps/cli/src/markerBlock.test.ts b/apps/cli/src/markerBlock.test.ts new file mode 100644 index 0000000..59b91eb --- /dev/null +++ b/apps/cli/src/markerBlock.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { blockRange, hasBlock, withBlock, withoutBlock } from './markerBlock.js'; + +const HASH = { start: '# >>> ailoud >>>', end: '# <<< ailoud <<<' }; +const block = `${HASH.start}\nexport PATH=x\n${HASH.end}`; + +describe('markerBlock', () => { + it('inserts into an empty file with no leading blank line', () => { + expect(withBlock('', block, HASH)).toBe(`${block}\n`); + }); + + it('appends after existing content with exactly one blank line', () => { + // Running install twice must produce the same bytes as running it once, + // or `update` on a schedule shows a diff every time it runs. + const once = withBlock('# my rc\n', block, HASH); + expect(once).toBe(`# my rc\n\n${block}\n`); + expect(withBlock(once, block, HASH)).toBe(once); + }); + + it('replaces an existing block and leaves the text around it alone', () => { + const before = `# top\n\n${HASH.start}\nold\n${HASH.end}\n\n# bottom\n`; + const after = withBlock(before, block, HASH); + expect(after).toContain('# top'); + expect(after).toContain('# bottom'); + expect(after).toContain('export PATH=x'); + expect(after).not.toContain('old'); + }); + + it('pairs the LAST start before the first end, not the first start', () => { + // A file that merely MENTIONS the marker once made the range run from + // that sentence to the end of the real block, deleting everything + // between -- including the user's own lines. + const before = `# we wrap ours in ${HASH.start} markers\n\n${block}\n`; + const range = blockRange(before, HASH)!; + expect(before.slice(range.from, range.to)).toBe(block); + expect(withoutBlock(before, HASH)).toContain('we wrap ours in'); + }); + + it('skips an END that has no START before it, and finds the real block after it', () => { + // A hand edit that deleted the START line, or prose quoting the closing + // marker, leaves an orphan END. Stopping at the first END reported "no + // block here" for a file that plainly has one: `install` then appended a + // second, duplicate block on every run, `self sync` and `self update` + // silently stopped refreshing, and `uninstall` printed "Nothing to remove" + // while leaving every block in place -- permanently, since nothing ever + // removes the orphan. + const before = `${HASH.end}\n\n# my rc\n\n${block}\n`; + expect(hasBlock(before, HASH)).toBe(true); + const range = blockRange(before, HASH)!; + expect(before.slice(range.from, range.to)).toBe(block); + + // The consequences the orphan caused, each asserted where it showed up. + const rewritten = withBlock(before, block, HASH); + expect(rewritten.split(HASH.start).length - 1).toBe(1); + expect(rewritten).toBe(before); + const removed = withoutBlock(before, HASH); + expect(removed).not.toBeNull(); + expect(removed).toContain('# my rc'); + expect(removed).not.toContain('export PATH=x'); + }); + + it('reports absence rather than an empty range', () => { + expect(blockRange('# nothing here\n', HASH)).toBeNull(); + expect(hasBlock('# nothing here\n', HASH)).toBe(false); + expect(withoutBlock('# nothing here\n', HASH)).toBeNull(); + }); + + it('returns an empty string when the block was the whole file', () => { + expect(withoutBlock(`${block}\n`, HASH)).toBe(''); + }); + + it('keeps two different marker pairs independent', () => { + // The whole reason this module is parameterised: the rules writer and + // the completions writer must not see each other's blocks. + const other = { start: '', end: '' }; + const text = withBlock('', block, HASH); + expect(hasBlock(text, other)).toBe(false); + expect(withoutBlock(text, other)).toBeNull(); + }); +}); diff --git a/apps/cli/src/markerBlock.ts b/apps/cli/src/markerBlock.ts new file mode 100644 index 0000000..e68e093 --- /dev/null +++ b/apps/cli/src/markerBlock.ts @@ -0,0 +1,97 @@ +/** + * A block of ailoud's own text inside a file that belongs to someone else. + * + * Markers rather than "append at the end": these files are hand-edited, so + * the only safe way to update our own text is to find exactly what we wrote + * last time and replace it. Everything outside the markers is untouched on + * every write. + * + * Parameterised by the marker pair because two callers need it with + * different comment syntax -- `` for a Markdown rules file, + * `# ...` for a shell startup file -- and one implementation of the range + * calculation below is the point. It has already been fixed once for a + * defect that destroyed user text; a second copy is a second place for that + * to come back. + */ +export interface Markers { + readonly start: string; + readonly end: string; +} + +/** + * Where our block sits, or null. + * + * The START taken is the LAST one before the END it is paired with, not the + * first one in the file. Pairing the first START with the first END destroyed + * user text: a file that merely MENTIONS the marker -- "we wrap our rules in + * markers" -- made the range run from that sentence to + * the end of our real block, and everything in between was replaced or deleted. + * + * The scan walks ENDs and skips any that has no START before it, rather than + * giving up on the first one. An END with no START is not hypothetical: a hand + * edit that deleted the START line, or prose quoting the closing marker, leaves + * one. Stopping there reported "no block here" for a file that plainly has one, + * which made `install` append a second, duplicate block on every run, made + * `self sync`/`self update` silently stop refreshing, and made `uninstall` + * print "Nothing to remove" while leaving every block in place -- permanently, + * because nothing ever removes the orphan. + */ +export function blockRange( + text: string, + markers: Markers, +): { readonly from: number; readonly to: number } | null { + let at = 0; + for (;;) { + const end = text.indexOf(markers.end, at); + if (end === -1) return null; + const from = text.lastIndexOf(markers.start, end); + // `from + start.length <= end` rejects a START that overlaps the END it + // would be paired with, which is reachable when one marker is a prefix of + // the other. A range whose start runs past its own end is not a block. + if (from !== -1 && from + markers.start.length <= end) { + return { from, to: end + markers.end.length }; + } + at = end + markers.end.length; + } +} + +/** Whether a file already carries our block. */ +export function hasBlock(text: string, markers: Markers): boolean { + return blockRange(text, markers) !== null; +} + +/** + * Inserts or replaces the block, returning the whole file. + * + * Appended with one blank line before it when absent, which is what makes the + * result stable: writing twice produces the same bytes as writing once, so a + * refresh is safe to run on a schedule and a diff after it shows only what + * actually changed. + */ +export function withBlock(text: string, block: string, markers: Markers): string { + const range = blockRange(text, markers); + if (range === null) { + const base = text.trimEnd(); + return base === '' ? `${block}\n` : `${base}\n\n${block}\n`; + } + return `${text.slice(0, range.from)}${block}${text.slice(range.to)}`; +} + +/** + * Removes the block, returning the whole file, or null when there was none. + * + * Null rather than the unchanged text so a caller can tell "removed" from + * "there was nothing of ours here" and report the difference -- an uninstall + * that claims to have cleaned a file it never touched teaches the user to + * distrust it. + */ +export function withoutBlock(text: string, markers: Markers): string | null { + const range = blockRange(text, markers); + if (range === null) return null; + const before = text.slice(0, range.from).replace(/\n+$/, ''); + const after = text.slice(range.to).replace(/^\n+/, ''); + if (before === '' && after === '') return ''; + if (before === '') return `${after.trimEnd()}\n`; + if (after === '') return `${before}\n`; + return `${before}\n\n${after.trimEnd()}\n`; +} diff --git a/apps/cli/src/mcp/agents.ts b/apps/cli/src/mcp/agents.ts index f0740eb..cf8ad82 100644 --- a/apps/cli/src/mcp/agents.ts +++ b/apps/cli/src/mcp/agents.ts @@ -1,5 +1,6 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; +import type { PermissionFormat } from './permissions.js'; /** Where a registration goes: this project only, or the whole machine. */ export type Scope = 'local' | 'global'; @@ -23,16 +24,34 @@ export interface AgentTarget { /** The MCP configuration file for a scope. */ configPath(scope: Scope, home: string, cwd: string): string; /** - * The rules file for a scope, or null when the agent reads none. + * The rules files for a scope, in preference order, or empty when the agent + * reads none. * - * A list, in preference order: the first that already exists is used, and - * the first entry is created when none do. That is what keeps the block in a - * repository's own `CLAUDE.md` instead of creating a second one under - * `.claude/` beside it. + * Every candidate that already carries the block is kept current; when no + * candidate carries it, it is created in the first. See `rulesTargets`. */ rulesPaths(scope: Scope, home: string, cwd: string): readonly string[]; /** Paths whose existence means this agent is installed on this machine. */ detectPaths(home: string): readonly string[]; + /** + * Where this agent keeps its command allow-list, or absent when it has none + * a third party can write. + * + * Separate from `configPath` because three of these agents keep permissions + * in a different file from their MCP servers, and one (Codex) keeps one + * policy for the machine while reading its MCP configuration per project. + */ + readonly permission?: { + readonly format: PermissionFormat; + path(scope: Scope, home: string, cwd: string): string; + /** + * The grant covers the directory it was made in and nothing else, whatever + * the file's own location suggests. True for Copilot alone: its + * permissions file is machine-wide, but every entry in it is keyed by an + * absolute path, so naming the file overstates what was approved. + */ + readonly directoryScoped?: boolean; + }; /** Printed after a successful write. Agents differ in what it takes to pick up a change. */ readonly afterNote: string; } @@ -58,8 +77,15 @@ export const AGENTS: readonly AgentTarget[] = [ rulesPaths: (scope, home, cwd) => scope === 'global' ? [join(home, '.claude', 'CLAUDE.md')] - : [join(cwd, 'CLAUDE.md'), join(cwd, '.claude', 'CLAUDE.md')], + : [join(cwd, '.claude', 'CLAUDE.md'), join(cwd, 'CLAUDE.md')], detectPaths: (home) => [join(home, '.claude.json'), join(home, '.claude')], + permission: { + format: 'json-claude-permissions', + path: (scope, home, cwd) => + scope === 'global' + ? join(home, '.claude', 'settings.json') + : join(cwd, '.claude', 'settings.json'), + }, afterNote: 'Restart Claude Code, or run /mcp, to pick up the server.', }, { @@ -72,6 +98,11 @@ export const AGENTS: readonly AgentTarget[] = [ rulesPaths: (scope, home, cwd) => scope === 'global' ? [join(home, '.codex', 'AGENTS.md')] : [join(cwd, 'AGENTS.md')], detectPaths: (home) => [join(home, '.codex')], + // Codex -- one policy for the machine, in either scope. + permission: { + format: 'yaml-codex-policy', + path: (_scope, home) => join(home, '.codex', 'policy.yaml'), + }, afterNote: 'Codex applies a project config only in a project you have marked trusted; trust this project to activate it.', }, @@ -89,6 +120,14 @@ export const AGENTS: readonly AgentTarget[] = [ ? [join(home, '.config', 'opencode', 'AGENTS.md')] : [join(cwd, 'AGENTS.md')], detectPaths: (home) => [join(home, '.config', 'opencode')], + // opencode -- the same file as the MCP configuration. + permission: { + format: 'jsonc-opencode-permission', + path: (scope, home, cwd) => + scope === 'global' + ? join(home, '.config', 'opencode', 'opencode.jsonc') + : join(cwd, 'opencode.jsonc'), + }, afterNote: 'Restart opencode to pick up the server.', }, { @@ -103,6 +142,14 @@ export const AGENTS: readonly AgentTarget[] = [ rulesPaths: (scope, home, cwd) => scope === 'global' ? [join(home, '.gemini', 'GEMINI.md')] : [join(cwd, 'GEMINI.md')], detectPaths: (home) => [join(home, '.gemini')], + // gemini -- the same file as the MCP configuration. + permission: { + format: 'json-gemini-tools', + path: (scope, home, cwd) => + scope === 'global' + ? join(home, '.gemini', 'settings.json') + : join(cwd, '.gemini', 'settings.json'), + }, afterNote: 'Restart the Gemini CLI to pick up the server.', }, { @@ -114,6 +161,10 @@ export const AGENTS: readonly AgentTarget[] = [ configPath: (_scope, home) => join(home, '.hermes', 'config.yaml'), rulesPaths: (_scope, home) => [join(home, '.hermes', 'AGENTS.md')], detectPaths: (home) => [join(home, '.hermes')], + // No permission member: Hermes persists approval patterns itself when a + // user answers "always", and publishes no key for a third party to write. + // Guessing one would write a file nothing reads, which looks exactly like + // a successful install. afterNote: 'Start a new Hermes session for the change to take effect.', }, { @@ -124,6 +175,12 @@ export const AGENTS: readonly AgentTarget[] = [ configPath: (_scope, home) => join(home, '.copilot', 'mcp-config.json'), rulesPaths: (_scope, home) => [join(home, '.copilot', 'copilot-instructions.md')], detectPaths: (home) => [join(home, '.copilot')], + // copilot -- keyed by directory inside one machine-wide file. + permission: { + format: 'json-copilot-locations', + path: (_scope, home) => join(home, '.copilot', 'permissions-config.json'), + directoryScoped: true, + }, afterNote: 'Restart any running Copilot CLI session to pick up the server.', }, ]; diff --git a/apps/cli/src/mcp/install.test.ts b/apps/cli/src/mcp/install.test.ts index d548397..9b05557 100644 --- a/apps/cli/src/mcp/install.test.ts +++ b/apps/cli/src/mcp/install.test.ts @@ -3,14 +3,14 @@ import { MemFs } from '@ailoud/core/testing'; import { findAgent } from './agents.js'; import { PROJECT_GITIGNORE, - chooseRulesFile, detect, ensureProjectLibrary, install, + rulesTargets, uninstall, update, } from './install.js'; -import { START } from './rulesBlock.js'; +import { START, withBlock } from './rulesBlock.js'; const HOME = '/home/ann'; const CWD = '/work/repo'; @@ -29,71 +29,127 @@ describe('detect', () => { }); }); -describe('chooseRulesFile', () => { - it('prefers a rules file that already exists', async () => { - // Claude Code reads both CLAUDE.md and .claude/CLAUDE.md; creating the - // second beside an existing first would split a project's instructions. +describe('rulesTargets', () => { + it('creates .claude/CLAUDE.md when the block is nowhere yet', async () => { + // Preferred over a root CLAUDE.md the project already hand-wrote: the + // block is ours, and a file of our own keeps it out of the user's. const fs = new MemFs({}); - await fs.writeTextFile(`${CWD}/CLAUDE.md`, '# P'); - expect(await chooseRulesFile(fs, claude, 'local', HOME, CWD)).toBe(`${CWD}/CLAUDE.md`); + await fs.writeTextFile(`${CWD}/CLAUDE.md`, '# Project rules'); + expect(await rulesTargets(fs, claude, 'local', HOME, CWD)).toEqual([ + `${CWD}/.claude/CLAUDE.md`, + ]); }); - it('falls back to the first candidate when none exists', async () => { + it('leaves a block that already lives in the root CLAUDE.md where it is', async () => { + // Moving it would be a delete plus a create in a hand-edited file for no + // user-visible gain, and a half-completed move leaves it in neither. const fs = new MemFs({}); - expect(await chooseRulesFile(fs, claude, 'local', HOME, CWD)).toBe(`${CWD}/CLAUDE.md`); + await fs.writeTextFile(`${CWD}/CLAUDE.md`, withBlock('# Project rules')); + expect(await rulesTargets(fs, claude, 'local', HOME, CWD)).toEqual([`${CWD}/CLAUDE.md`]); + }); + + it('returns both files when both already carry the block', async () => { + // Claude Code reads both, so a block left behind in one of them keeps + // telling the agent something that stopped being true. + const fs = new MemFs({}); + await fs.writeTextFile(`${CWD}/CLAUDE.md`, withBlock('# Project rules')); + await fs.writeTextFile(`${CWD}/.claude/CLAUDE.md`, withBlock('# More rules')); + expect(await rulesTargets(fs, claude, 'local', HOME, CWD)).toEqual([ + `${CWD}/.claude/CLAUDE.md`, + `${CWD}/CLAUDE.md`, + ]); + }); + + it('falls back to the first candidate when nothing exists at all', async () => { + const fs = new MemFs({}); + expect(await rulesTargets(fs, claude, 'local', HOME, CWD)).toEqual([ + `${CWD}/.claude/CLAUDE.md`, + ]); + }); + + it('is a no-op for a scope that lists one candidate', async () => { + const fs = new MemFs({}); + expect(await rulesTargets(fs, claude, 'global', HOME, CWD)).toEqual([ + `${HOME}/.claude/CLAUDE.md`, + ]); }); }); describe('install', () => { it('writes both the config and the rules, because either alone is half the feature', async () => { const fs = new MemFs({}); - const outcome = await install(fs, claude, 'local', HOME, CWD); + const outcome = await install(fs, claude, 'local', HOME, CWD, false); const byPath = actions(outcome.files); expect(byPath[`${CWD}/.mcp.json`]).toBe('created'); - expect(byPath[`${CWD}/CLAUDE.md`]).toBe('created'); + expect(byPath[`${CWD}/.claude/CLAUDE.md`]).toBe('created'); expect(await fs.readTextFile(`${CWD}/.mcp.json`)).toContain('ailoud'); - expect(await fs.readTextFile(`${CWD}/CLAUDE.md`)).toContain(START); + expect(await fs.readTextFile(`${CWD}/.claude/CLAUDE.md`)).toContain(START); }); it('reports unchanged on a second run rather than claiming a write', async () => { const fs = new MemFs({}); - await install(fs, claude, 'local', HOME, CWD); - const second = await install(fs, claude, 'local', HOME, CWD); + await install(fs, claude, 'local', HOME, CWD, false); + const second = await install(fs, claude, 'local', HOME, CWD, false); expect(Object.values(actions(second.files))).toEqual(['unchanged', 'unchanged']); }); it('carries the note that says what it takes to pick up the change', async () => { const fs = new MemFs({}); - const outcome = await install(fs, claude, 'local', HOME, CWD); + const outcome = await install(fs, claude, 'local', HOME, CWD, false); expect(outcome.note).toMatch(/Restart Claude Code/); }); it('writes a global-only agent into the home directory', async () => { const fs = new MemFs({}); - const outcome = await install(fs, hermes, 'global', HOME, CWD); + const outcome = await install(fs, hermes, 'global', HOME, CWD, false); expect(Object.keys(actions(outcome.files))[0]).toContain(`${HOME}/.hermes`); }); + + it('updates every file that already carries the block', async () => { + const fs = new MemFs({}); + await fs.writeTextFile(`${CWD}/CLAUDE.md`, `# Root\n\n${START}\nstale\n\n`); + await fs.writeTextFile( + `${CWD}/.claude/CLAUDE.md`, + `# Nested\n\n${START}\nstale\n\n`, + ); + const outcome = await install(fs, claude, 'local', HOME, CWD, false); + const byPath = actions(outcome.files); + expect(byPath[`${CWD}/CLAUDE.md`]).toBe('updated'); + expect(byPath[`${CWD}/.claude/CLAUDE.md`]).toBe('updated'); + // The user's own text on either side of the markers survives. + expect(await fs.readTextFile(`${CWD}/CLAUDE.md`)).toContain('# Root'); + expect(await fs.readTextFile(`${CWD}/.claude/CLAUDE.md`)).toContain('# Nested'); + expect(await fs.readTextFile(`${CWD}/CLAUDE.md`)).toContain('search_transcripts'); + }); + + it('creates the nested rules file rather than appending to a root CLAUDE.md', async () => { + const fs = new MemFs({}); + await fs.writeTextFile(`${CWD}/CLAUDE.md`, '# Project rules\n'); + await install(fs, claude, 'local', HOME, CWD, false); + expect(await fs.readTextFile(`${CWD}/CLAUDE.md`)).toBe('# Project rules\n'); + expect(await fs.readTextFile(`${CWD}/.claude/CLAUDE.md`)).toContain(START); + }); }); describe('uninstall', () => { it('deletes a file it created and edits one the user owns', async () => { const fs = new MemFs({}); - await fs.writeTextFile(`${CWD}/CLAUDE.md`, '# My Project\n'); - await install(fs, claude, 'local', HOME, CWD); + await fs.writeTextFile(`${CWD}/.claude/CLAUDE.md`, '# My Project\n'); + await install(fs, claude, 'local', HOME, CWD, false); const outcome = await uninstall(fs, claude, 'local', HOME, CWD); const byPath = actions(outcome.files); expect(byPath[`${CWD}/.mcp.json`]).toBe('removed'); - expect(byPath[`${CWD}/CLAUDE.md`]).toBe('cleaned'); + expect(byPath[`${CWD}/.claude/CLAUDE.md`]).toBe('cleaned'); expect(await fs.exists(`${CWD}/.mcp.json`)).toBe(false); - expect(await fs.readTextFile(`${CWD}/CLAUDE.md`)).toBe('# My Project\n'); + expect(await fs.readTextFile(`${CWD}/.claude/CLAUDE.md`)).toBe('# My Project\n'); }); it('removes a rules file that existed only for the block', async () => { const fs = new MemFs({}); - await install(fs, claude, 'local', HOME, CWD); + await install(fs, claude, 'local', HOME, CWD, false); await uninstall(fs, claude, 'local', HOME, CWD); - expect(await fs.exists(`${CWD}/CLAUDE.md`)).toBe(false); + expect(await fs.exists(`${CWD}/.claude/CLAUDE.md`)).toBe(false); }); it('reports absent rather than a cleanup it did not do', async () => { @@ -106,12 +162,12 @@ describe('uninstall', () => { // An earlier install may have written into the other candidate; leaving // that block would keep telling the agent about tools it no longer has. const fs = new MemFs({}); - await install(fs, claude, 'local', HOME, CWD); - const block = await fs.readTextFile(`${CWD}/CLAUDE.md`); - await fs.writeTextFile(`${CWD}/.claude/CLAUDE.md`, block); + await install(fs, claude, 'local', HOME, CWD, false); + const block = await fs.readTextFile(`${CWD}/.claude/CLAUDE.md`); + await fs.writeTextFile(`${CWD}/CLAUDE.md`, block); await uninstall(fs, claude, 'local', HOME, CWD); - expect(await fs.exists(`${CWD}/.claude/CLAUDE.md`)).toBe(false); + expect(await fs.exists(`${CWD}/CLAUDE.md`)).toBe(false); }); }); @@ -125,7 +181,7 @@ describe('update', () => { it('refreshes a stale block in place', async () => { const fs = new MemFs({}); - await install(fs, claude, 'local', HOME, CWD); + await install(fs, claude, 'local', HOME, CWD, false); await fs.writeTextFile(`${CWD}/CLAUDE.md`, `${START}\nold\n\n`); const outcome = await update(fs, claude, 'local', HOME, CWD); expect(outcome).not.toBeNull(); @@ -141,6 +197,197 @@ describe('update', () => { }); }); +describe('install with the allow-list', () => { + it('writes nothing about permissions when it was not asked to', async () => { + const fs = new MemFs({}); + const outcome = await install(fs, claude, 'local', HOME, CWD, false); + expect(actions(outcome.files)[`${CWD}/.claude/settings.json`]).toBeUndefined(); + expect(await fs.exists(`${CWD}/.claude/settings.json`)).toBe(false); + }); + + it('adds the rule when it was asked to', async () => { + const fs = new MemFs({}); + const outcome = await install(fs, claude, 'local', HOME, CWD, true); + expect(actions(outcome.files)[`${CWD}/.claude/settings.json`]).toBe('created'); + const settings = JSON.parse(await fs.readTextFile(`${CWD}/.claude/settings.json`)); + expect(settings.permissions.allow).toEqual(['Bash(ailoud:*)']); + }); + + it('skips a settings file it cannot parse rather than destroying it', async () => { + const fs = new MemFs({}); + await fs.writeTextFile(`${CWD}/.claude/settings.json`, '{ broken'); + const outcome = await install(fs, claude, 'local', HOME, CWD, true); + expect(actions(outcome.files)[`${CWD}/.claude/settings.json`]).toBe('skipped'); + expect(await fs.readTextFile(`${CWD}/.claude/settings.json`)).toBe('{ broken'); + const skipped = outcome.files.find((file) => file.action === 'skipped')!; + expect(skipped.detail).toContain('not valid JSON'); + }); + + it('says which refusal it was, per agent, rather than one catch-all', async () => { + // The three reasons an allow-list is left alone are different problems. + // Reported as one, they send a Codex user looking for a JSON error in a + // YAML file and an opencode user looking for a syntax error in a file + // whose syntax is fine. + const fs = new MemFs({}); + await fs.writeTextFile(`${HOME}/.codex/policy.yaml`, 'allow: everything\n'); + await fs.writeTextFile(`${CWD}/opencode.jsonc`, '{"permission":"ask"}'); + + const codexFiles = (await install(fs, findAgent('codex')!, 'local', HOME, CWD, true)).files; + const codex = codexFiles.find((file) => file.action === 'skipped')!; + expect(codex.detail).not.toContain('JSON'); + + const openFiles = (await install(fs, findAgent('opencode')!, 'local', HOME, CWD, true)).files; + const open = openFiles.find((file) => file.action === 'skipped')!; + expect(open.detail).toContain('every tool'); + }); + + it('reports nothing for an agent with no allow-list of its own', async () => { + const fs = new MemFs({}); + const outcome = await install(fs, hermes, 'global', HOME, CWD, true); + expect(outcome.files.every((file) => !file.path.endsWith('policy.yaml'))).toBe(true); + }); + + it('sends a local Codex install to the machine-wide policy file', async () => { + // Codex reads one policy for the machine, unlike its MCP configuration. + const fs = new MemFs({}); + const codex = findAgent('codex')!; + const outcome = await install(fs, codex, 'local', HOME, CWD, true); + expect(actions(outcome.files)[`${HOME}/.codex/policy.yaml`]).toBe('created'); + }); +}); + +describe('uninstall with the allow-list', () => { + it('takes the rule back out and leaves the rest of the file alone', async () => { + const fs = new MemFs({}); + await fs.writeTextFile( + `${CWD}/.claude/settings.json`, + JSON.stringify({ permissions: { allow: ['Bash(ailoud:*)'] }, hooks: { a: 1 } }), + ); + const outcome = await uninstall(fs, claude, 'local', HOME, CWD); + expect(actions(outcome.files)[`${CWD}/.claude/settings.json`]).toBe('cleaned'); + const settings = JSON.parse(await fs.readTextFile(`${CWD}/.claude/settings.json`)); + expect(settings.permissions).toBeUndefined(); + expect(settings.hooks).toEqual({ a: 1 }); + }); + + /** + * The property, for every agent and however the install was answered: a + * file holding nothing but what AILoud put there is deleted. + * + * Both shapes are listed, because the leftover looked different in each. + * For Claude Code and Codex the allow-list is a file of its own, and the + * uninstall wrote `{}` back into it. For opencode and Gemini it IS the MCP + * config, and the MCP step runs first: `isEmptyConfig` still saw our + * permission keys, read them as the user's settings and kept the file -- + * so an install answered with the allow-list left a husk behind where one + * answered without it deleted the file outright. + */ + const agents = [ + { id: 'claude', config: `${CWD}/.mcp.json`, permission: `${CWD}/.claude/settings.json` }, + { + id: 'gemini', + config: `${CWD}/.gemini/settings.json`, + permission: `${CWD}/.gemini/settings.json`, + }, + { id: 'opencode', config: `${CWD}/opencode.jsonc`, permission: `${CWD}/opencode.jsonc` }, + { id: 'codex', config: `${CWD}/.codex/config.toml`, permission: `${HOME}/.codex/policy.yaml` }, + ] as const; + + it.each(agents)('leaves nothing of $id behind after an allow-shell install', async (agent) => { + const fs = new MemFs({}); + const target = findAgent(agent.id)!; + await install(fs, target, 'local', HOME, CWD, true); + expect(await fs.exists(agent.permission)).toBe(true); + + await uninstall(fs, target, 'local', HOME, CWD); + expect(await fs.exists(agent.config)).toBe(false); + expect(await fs.exists(agent.permission)).toBe(false); + }); + + it.each(agents)('leaves nothing of $id behind without one either', async (agent) => { + const fs = new MemFs({}); + const target = findAgent(agent.id)!; + await install(fs, target, 'local', HOME, CWD, false); + + await uninstall(fs, target, 'local', HOME, CWD); + expect(await fs.exists(agent.config)).toBe(false); + expect(await fs.exists(agent.permission)).toBe(false); + }); + + it('leaves nothing of Copilot behind, whose allow-list is a file of its own', async () => { + const fs = new MemFs({}); + const copilot = findAgent('copilot')!; + await install(fs, copilot, 'global', HOME, CWD, true); + + await uninstall(fs, copilot, 'global', HOME, CWD); + expect(await fs.exists(`${HOME}/.copilot/mcp-config.json`)).toBe(false); + expect(await fs.exists(`${HOME}/.copilot/permissions-config.json`)).toBe(false); + }); + + it('keeps a settings file that still holds a setting the user wrote', async () => { + const fs = new MemFs({}); + await install(fs, claude, 'local', HOME, CWD, true); + const settings = JSON.parse(await fs.readTextFile(`${CWD}/.claude/settings.json`)); + await fs.writeTextFile( + `${CWD}/.claude/settings.json`, + JSON.stringify({ ...settings, model: 'opus' }), + ); + + await uninstall(fs, claude, 'local', HOME, CWD); + expect(await fs.exists(`${CWD}/.claude/settings.json`)).toBe(true); + expect(JSON.parse(await fs.readTextFile(`${CWD}/.claude/settings.json`))).toEqual({ + model: 'opus', + }); + }); +}); + +describe('update with the allow-list', () => { + it('never grants a permission that was not already there', async () => { + // self sync sweeps this across every registered project unattended. + // Widening an agent's privileges without being asked is the one thing it + // must not do. + const fs = new MemFs({}); + await install(fs, claude, 'local', HOME, CWD, false); + await update(fs, claude, 'local', HOME, CWD); + expect(await fs.exists(`${CWD}/.claude/settings.json`)).toBe(false); + }); + + it('refreshes a permission that is already there', async () => { + const fs = new MemFs({}); + await install(fs, claude, 'local', HOME, CWD, true); + const outcome = await update(fs, claude, 'local', HOME, CWD); + expect(actions(outcome!.files)[`${CWD}/.claude/settings.json`]).toBe('unchanged'); + }); + + it('never treats a stray permission entry as evidence the agent was configured here', async () => { + // Codex's policy.yaml is one file for the whole machine. A different + // project's install may have already put our entry there; this project + // has no Codex MCP configuration and no rules block of its own. + const fs = new MemFs({}); + const codex = findAgent('codex')!; + await fs.writeTextFile( + `${HOME}/.codex/policy.yaml`, + '# AILoud permissions\nallow:\n - "ailoud"\n - "ailoud *"\n', + ); + expect(await update(fs, codex, 'local', HOME, CWD)).toBeNull(); + expect(await fs.exists(`${CWD}/.codex/config.toml`)).toBe(false); + expect(await fs.exists(`${CWD}/AGENTS.md`)).toBe(false); + }); + + it('refreshes the permission entry rather than dropping it when the agent is configured here', async () => { + const fs = new MemFs({}); + const codex = findAgent('codex')!; + await install(fs, codex, 'local', HOME, CWD, true); + const before = await fs.readTextFile(`${HOME}/.codex/policy.yaml`); + + const outcome = await update(fs, codex, 'local', HOME, CWD); + + expect(outcome).not.toBeNull(); + expect(actions(outcome!.files)[`${HOME}/.codex/policy.yaml`]).toBe('unchanged'); + expect(await fs.readTextFile(`${HOME}/.codex/policy.yaml`)).toBe(before); + }); +}); + describe('ensureProjectLibrary', () => { it('creates the directory with an ignore file that keeps its contents out of git', async () => { const fs = new MemFs({}); @@ -160,3 +407,45 @@ describe('ensureProjectLibrary', () => { expect(await fs.readTextFile(`${CWD}/.ailoud/.gitignore`)).toContain('notes.md'); }); }); + +describe('the rules file is written atomically', () => { + /** Records the order of writes and renames, so the mechanism is checkable. */ + class RecordingFs extends MemFs { + public readonly calls: string[] = []; + public override async writeTextFile(path: string, content: string): Promise { + this.calls.push(`write:${path}`); + return super.writeTextFile(path, content); + } + public override async rename(from: string, to: string): Promise { + this.calls.push(`rename:${from}->${to}`); + return super.rename(from, to); + } + } + + it('writes a temporary file and renames it over the target', async () => { + // The MECHANISM is what this asserts, deliberately. The defect it guards + // against -- `writeTextFile` truncating the target before a failed write + // empties it -- cannot be reproduced with `MemFs`, which either writes or + // throws atomically. Truncation is a property of the real POSIX + // `open(path, 'w')`. + // + // It was demonstrated on a real filesystem instead: on a full 1 MB + // volume, a plain write turned a 25-byte hand-written CLAUDE.md into 0 + // bytes with ENOSPC, while temp-then-rename left it byte-identical. That + // is why this pattern is here, and `self sync` sweeping this writer + // across every registered project unattended is why it matters. + const fs = new RecordingFs({ '/proj/.claude/CLAUDE.md': '# My own notes\n' }); + + await install(fs, findAgent('claude')!, 'local', '/home/x', '/proj', false); + + const rules = fs.calls.filter((call) => call.includes('CLAUDE.md')); + expect(rules.some((call) => call.startsWith('write:') && call.includes('.tmp'))).toBe(true); + expect( + rules.some( + (call) => call.startsWith('rename:') && call.endsWith('->/proj/.claude/CLAUDE.md'), + ), + ).toBe(true); + // And never a direct write to the target itself. + expect(rules).not.toContain('write:/proj/.claude/CLAUDE.md'); + }); +}); diff --git a/apps/cli/src/mcp/install.ts b/apps/cli/src/mcp/install.ts index 1047ea2..175b34b 100644 --- a/apps/cli/src/mcp/install.ts +++ b/apps/cli/src/mcp/install.ts @@ -1,14 +1,30 @@ +import { randomUUID } from 'node:crypto'; import { dirname, join } from 'node:path'; import type { Fs } from '@ailoud/core'; import { PROJECT_DIR } from '../config.js'; import { addServer, hasServer, isEmptyConfig, removeServer } from './agentConfig.js'; import type { AgentTarget, Scope } from './agents.js'; +import { addPermission, describeRefusal, hasPermission, removePermission } from './permissions.js'; import { hasBlock, withBlock, withoutBlock } from './rulesBlock.js'; /** What happened to one file, for the report a command prints. */ export interface FileOutcome { readonly path: string; - readonly action: 'created' | 'updated' | 'unchanged' | 'removed' | 'cleaned' | 'absent'; + /** + * `skipped` is the allow-list writer declining to rewrite a settings file + * it will not touch safely. Distinct from `unchanged`, which means nothing + * needed doing: this one means the user asked for something and did not get + * it. `detail` below says which refusal it was. + */ + readonly action: + 'created' | 'updated' | 'unchanged' | 'removed' | 'cleaned' | 'absent' | 'skipped'; + /** + * Why, for an action that does not say on its own. Only `skipped` carries + * one: the reasons the allow-list writer declines are different problems + * with different fixes, and one catch-all sentence sent users looking for + * a fault their file did not have. + */ + readonly detail?: string; } export interface AgentOutcome { @@ -26,37 +42,104 @@ export async function detect(fs: Fs, agent: AgentTarget, home: string): Promise< return false; } +async function readIfPresent(fs: Fs, path: string): Promise { + return (await fs.exists(path)) ? fs.readTextFile(path) : null; +} + /** - * The rules file to write for a scope: the first that exists, else the first - * listed. + * The rules files to write for a scope. + * + * Every candidate that already carries the block, or the first candidate when + * none does. * - * Preference order matters for Claude Code, which reads both a repository's - * own `CLAUDE.md` and a `.claude/CLAUDE.md` beside it. Appending to the one - * that already exists keeps a project's instructions in one file; creating - * `.claude/CLAUDE.md` next to an existing `CLAUDE.md` would split them. + * Claude Code reads both a repository's own `CLAUDE.md` and a + * `.claude/CLAUDE.md` beside it, which makes both halves of that rule + * load-bearing. Writing to both on a fresh install would put the same + * instructions in the agent's context twice; writing to only the preferred one + * when an earlier install left a block in the other would leave that copy to + * go stale and keep telling the agent about tools it no longer has. */ -export async function chooseRulesFile( +export async function rulesTargets( fs: Fs, agent: AgentTarget, scope: Scope, home: string, cwd: string, -): Promise { +): Promise { const candidates = agent.rulesPaths(scope, home, cwd); - if (candidates.length === 0) return null; + if (candidates.length === 0) return []; + const carrying: string[] = []; for (const path of candidates) { - if (await fs.exists(path)) return path; + const text = await readIfPresent(fs, path); + if (text !== null && hasBlock(text)) carrying.push(path); } - return candidates[0] ?? null; -} - -async function readIfPresent(fs: Fs, path: string): Promise { - return (await fs.exists(path)) ? fs.readTextFile(path) : null; + return carrying.length > 0 ? carrying : [candidates[0]!]; } +/** + * Writes a file without ever leaving it half-written: a temporary file beside + * it, then a rename over the top. + * + * `writeTextFile` truncates before it writes, so a failure part-way through -- + * ENOSPC is the realistic one -- leaves the target EMPTY. That was survivable + * while these files were only touched by an interactive `mcp install` the user + * was watching. It is not survivable now: `self sync` sweeps this writer + * across every registered project unattended, and the file it rewrites is + * often a repository's own hand-written `CLAUDE.md` or `AGENTS.md`. Truncating + * one of those and then reporting `failed` destroys the user's content while + * telling them nothing happened. + * + * Same pattern as `writeRegistry` in `apps/cli/src/projects.ts`, and for the + * same reason. The temporary name is randomised so two concurrent writers + * cannot corrupt each other's, and it sits in the target's own directory so + * the rename stays on one filesystem and therefore stays atomic. + */ async function write(fs: Fs, path: string, content: string): Promise { await fs.ensureDir(dirname(path)); - await fs.writeTextFile(path, content); + const temp = `${path}.${randomUUID()}.tmp`; + try { + await fs.writeTextFile(temp, content); + } catch (error) { + // The target has not been touched yet, so there is nothing to undo. Clear + // the partial temporary file rather than leaving litter beside a config. + await fs.removeFile(temp); + throw error; + } + await fs.rename(temp, path); +} + +/** + * Writes the agent's command allow-list, when it has one and was asked for. + * + * Returns nothing to report for an agent with no allow-list, rather than a + * row saying so for every agent on every run. + */ +async function writePermission( + fs: Fs, + agent: AgentTarget, + scope: Scope, + home: string, + cwd: string, +): Promise { + if (agent.permission === undefined) return null; + const path = agent.permission.path(scope, home, cwd); + const before = await readIfPresent(fs, path); + const edit = addPermission(agent.permission.format, before, cwd); + if (!edit.ok) { + return { + path, + action: 'skipped', + detail: describeRefusal(agent.permission.format, edit.reason), + }; + } + const after = edit.text; + if (before === null) { + await write(fs, path, after); + return { path, action: 'created' }; + } + if (before === after) return { path, action: 'unchanged' }; + await write(fs, path, after); + return { path, action: 'updated' }; } /** @@ -65,7 +148,8 @@ async function write(fs: Fs, path: string, content: string): Promise { * Both files are written: the MCP configuration, which is what makes the tools * reachable, and the rules block, which is what makes the agent use them well. * Either alone is half the feature -- an agent with the tools and no guidance - * reads whole transcripts into its context. + * reads whole transcripts into its context. A third, the command allow-list, + * is written only when `allowShell` says the user asked for it. */ export async function install( fs: Fs, @@ -73,6 +157,7 @@ export async function install( scope: Scope, home: string, cwd: string, + allowShell: boolean, ): Promise { const files: FileOutcome[] = []; @@ -89,8 +174,7 @@ export async function install( files.push({ path: configPath, action: 'unchanged' }); } - const rulesPath = await chooseRulesFile(fs, agent, scope, home, cwd); - if (rulesPath !== null) { + for (const rulesPath of await rulesTargets(fs, agent, scope, home, cwd)) { const rulesBefore = await readIfPresent(fs, rulesPath); const rulesAfter = withBlock(rulesBefore ?? ''); if (rulesBefore === null) { @@ -104,6 +188,11 @@ export async function install( } } + if (allowShell) { + const permission = await writePermission(fs, agent, scope, home, cwd); + if (permission !== null) files.push(permission); + } + return { agent, scope, files, note: agent.afterNote }; } @@ -158,6 +247,34 @@ export async function uninstall( } } + // Symmetric with install, and unconditional: an uninstall that left a + // standing permission for a command the user just removed would be a + // privilege nobody can see the reason for any more. + if (agent.permission !== undefined) { + const path = agent.permission.path(scope, home, cwd); + const before = await readIfPresent(fs, path); + if (before === null) { + files.push({ path, action: 'absent' }); + } else { + const after = removePermission(agent.permission.format, before, cwd); + if (after === null) { + files.push({ path, action: 'unchanged' }); + } else if (after === '') { + // Decided here rather than left to the `isEmptyConfig` check above: + // for opencode and Gemini this IS the MCP configuration file, and + // that check ran first, while our permission keys were still in it. + // It therefore saw a file with settings in and kept it -- so an + // install that used the allow-list left `{}` behind where one that + // did not deleted the file outright. + await fs.removeFile(path); + files.push({ path, action: 'removed' }); + } else { + await write(fs, path, after); + files.push({ path, action: 'cleaned' }); + } + } + } + return { agent, scope, files, note: agent.afterNote }; } @@ -186,8 +303,18 @@ export async function update( if (text !== null && hasBlock(text)) rulesConfigured = true; } + // The allow-list is refreshed where it already is and never created here. + // `self sync` sweeps this across every registered project unattended, and + // widening an agent's privileges without being asked is the one thing that + // sweep must not do. + let allowShell = false; + if (agent.permission !== undefined) { + const text = await readIfPresent(fs, agent.permission.path(scope, home, cwd)); + allowShell = text !== null && hasPermission(agent.permission.format, text, cwd); + } + if (!configured && !rulesConfigured) return null; - return install(fs, agent, scope, home, cwd); + return install(fs, agent, scope, home, cwd, allowShell); } /** diff --git a/apps/cli/src/mcp/instructions.test.ts b/apps/cli/src/mcp/instructions.test.ts new file mode 100644 index 0000000..43ae5e9 --- /dev/null +++ b/apps/cli/src/mcp/instructions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { SERVER_INSTRUCTIONS } from './instructions.js'; + +describe('performance guidance', () => { + it('says the gpu is what makes transcription fast', () => { + expect(SERVER_INSTRUCTIONS).toMatch(/ten times faster/); + }); + + it('says threads matter without a gpu and barely matter with one', () => { + // Both halves, because the advice inverts between the two machines and + // half of it is actively wrong on the other. + expect(SERVER_INSTRUCTIONS).toMatch(/barely changes anything/i); + expect(SERVER_INSTRUCTIONS).toMatch(/four times/); + }); + + it('names diarization as the cpu-bound stage', () => { + expect(SERVER_INSTRUCTIONS).toMatch(/diarization always runs on the CPU/i); + }); + + it('tells the agent to run doctor rather than ask the user', () => { + expect(SERVER_INSTRUCTIONS).toMatch(/Do not ask the user about CPU or GPU/i); + expect(SERVER_INSTRUCTIONS).toMatch(/run .?doctor.?/); + }); + + it('never names the per-run flag', () => { + expect(SERVER_INSTRUCTIONS).not.toContain('--max-cpu'); + }); + + it('adds no seventh rule', () => { + // The guidance belongs inside rule 6, which already opens with what + // transcription costs. A seventh rule would dilute the six that matter. + expect(SERVER_INSTRUCTIONS).toContain('Six rules'); + expect(SERVER_INSTRUCTIONS).not.toMatch(/^7\./m); + }); + + it('tells the agent to ask who the numbered speakers are', () => { + expect(SERVER_INSTRUCTIONS).toContain('unnamedSpeakers'); + expect(SERVER_INSTRUCTIONS).toContain('annotate'); + }); +}); diff --git a/apps/cli/src/mcp/instructions.ts b/apps/cli/src/mcp/instructions.ts index 6872ce2..ea0b56f 100644 --- a/apps/cli/src/mcp/instructions.ts +++ b/apps/cli/src/mcp/instructions.ts @@ -62,9 +62,40 @@ Six rules, in the order they matter: told you and pass it again on the next summary of the same material; it is the one thing you know that the transcript does not say. -6. TRANSCRIBING AND SUMMARISING COST SOMETHING. Transcription is minutes of - CPU per recording. Summarising spends tokens on a hosted model or minutes - on a local one. Neither has a default selection: name the recordings. +6. TRANSCRIBING AND SUMMARISING COST SOMETHING, AND RUN IN THE BACKGROUND. + Transcription is minutes of CPU per recording. Summarising spends tokens on + a hosted model or minutes on a local one. Neither has a default selection: + name the recordings. + + Both return a JOB ID instead of a result. Poll \`job_status\` with it -- a + few minutes between calls is plenty, and polling faster does not make the + work finish sooner. A finished transcribe job lists the recordings it + transcribed; a finished summarize job carries the report id to read with + \`get_report\`. + + When a finished transcribe job reports \`unnamedSpeakers\`, ask the user who + those speakers are and record the answer with \`annotate\`. Only a person + knows which label is which, the transcript itself usually says enough to + guess and offer, and a name given once survives re-transcription and is + used by every later summary. + + How fast that is depends on the machine, not on anything you pass. A + build with a GPU backend transcribes about ten times faster than one + without, and on a GPU machine the thread count barely changes anything. + Without a GPU, more threads are worth about four times the speed. Speaker + diarization always runs on the CPU, so it is where a thread limit shows + most. Do not ask the user about CPU or GPU settings: the defaults are + already right. If they say transcription is slow, run \`doctor\` -- its + first line reports what this machine's build actually loaded, which + answers the question. + + \`transcribe\` will REFUSE until you say how many people speak and which + languages to expect. That is not bureaucracy: whisper's detector answers + with any language in the world, so on a Russian and English recording it + will sometimes report Polish for a Russian stretch, and that stretch comes + back as phonetic nonsense. Ask the user. Offer your own guess from the + recording's name -- you can see the conversation, and ailoud can only see + a filename. Ask per recording when the recordings differ. Deleting is deliberately awkward. \`delete_recording\` and \`delete_report\` never delete on the first call: they describe what would go and return a diff --git a/apps/cli/src/mcp/permissions.test.ts b/apps/cli/src/mcp/permissions.test.ts new file mode 100644 index 0000000..af06e09 --- /dev/null +++ b/apps/cli/src/mcp/permissions.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it } from 'vitest'; +import { parseDocument } from 'yaml'; +import { addPermission, describeRefusal, hasPermission, removePermission } from './permissions.js'; +import type { PermissionFormat } from './permissions.js'; + +const CWD = '/work/repo'; +const parse = (text: string) => JSON.parse(text); +/** The identifier Copilot is given, spelled once here so the tests read as data. */ +const RULE = 'ailoud:*'; + +/** The edited text, failing the test rather than the type checker on a refusal. */ +function added(format: PermissionFormat, previous: string | null): string { + const edit = addPermission(format, previous, CWD); + if (!edit.ok) throw new Error(`refused to edit the ${format} file: ${edit.reason}`); + return edit.text; +} + +describe('json-claude-permissions', () => { + it('creates the allow list when there is no file', () => { + const out = added('json-claude-permissions', null); + expect(parse(out).permissions.allow).toEqual(['Bash(ailoud:*)']); + }); + + it('keeps every other setting and every other rule verbatim', () => { + // These files are hand-edited. An install that dropped a user's hooks + // while adding one permission would be a worse outcome than not installing. + const before = JSON.stringify({ + permissions: { allow: ['Bash(git status)'], deny: ['Bash(rm:*)'] }, + hooks: { UserPromptSubmit: [{ command: 'x' }] }, + }); + const out = added('json-claude-permissions', before); + const root = parse(out); + expect(root.permissions.allow).toEqual(['Bash(git status)', 'Bash(ailoud:*)']); + expect(root.permissions.deny).toEqual(['Bash(rm:*)']); + expect(root.hooks.UserPromptSubmit).toEqual([{ command: 'x' }]); + }); + + it('is idempotent, so a second install reports unchanged rather than a write', () => { + const once = added('json-claude-permissions', null); + expect(added('json-claude-permissions', once)).toBe(once); + }); + + it('returns a hand-formatted file untouched when the rule is already there', () => { + // Reformatting a file that already has the rule would report a write on + // a file that needed none, on the very first install against it. + const input = '{"unrelated":true,"permissions":{"allow":["Bash(ailoud:*)"]}}'; + expect(added('json-claude-permissions', input)).toBe(input); + }); + + it('refuses to rewrite a file it cannot parse', () => { + // Rewriting it would destroy hand-written settings; the caller reports + // this as skipped and names the path. + expect(addPermission('json-claude-permissions', '{ this is not json', CWD)).toEqual({ + ok: false, + reason: 'unreadable', + }); + }); + + it('reports whether the rule is present', () => { + expect(hasPermission('json-claude-permissions', '{}', CWD)).toBe(false); + const once = added('json-claude-permissions', null); + expect(hasPermission('json-claude-permissions', once, CWD)).toBe(true); + }); + + it('removes only our rule, and says so when there was none', () => { + const before = JSON.stringify({ + permissions: { allow: ['Bash(git status)', 'Bash(ailoud:*)'] }, + }); + const out = removePermission('json-claude-permissions', before, CWD)!; + expect(parse(out).permissions.allow).toEqual(['Bash(git status)']); + expect(removePermission('json-claude-permissions', '{}', CWD)).toBeNull(); + }); + + it('clears the containers it emptied rather than leaving them behind', () => { + // `"permissions": {"allow": []}` records that an install once happened, + // which is what an uninstall is supposed to undo. + const before = JSON.stringify({ + hooks: { a: 1 }, + permissions: { allow: ['Bash(ailoud:*)'] }, + }); + const out = removePermission('json-claude-permissions', before, CWD)!; + expect(parse(out).permissions).toBeUndefined(); + expect(parse(out).hooks).toEqual({ a: 1 }); + }); + + it('empties a file that held nothing but our rule, so the caller can delete it', () => { + const once = added('json-claude-permissions', null); + expect(removePermission('json-claude-permissions', once, CWD)).toBe(''); + }); +}); + +describe('json-gemini-tools', () => { + it('writes tools.allowed, never tools.core', () => { + // tools.core is a restricting allowlist: writing into it would disable + // every other built-in tool the user has. + const out = added('json-gemini-tools', null); + expect(parse(out).tools.allowed).toEqual(['run_shell_command(ailoud)']); + expect(parse(out).tools.core).toBeUndefined(); + }); + + it('keeps an existing core allowlist untouched', () => { + const before = JSON.stringify({ tools: { core: ['read_file'] } }); + const out = added('json-gemini-tools', before); + expect(parse(out).tools.core).toEqual(['read_file']); + expect(parse(out).tools.allowed).toEqual(['run_shell_command(ailoud)']); + }); + + it('is idempotent, so a second install reports unchanged rather than a write', () => { + const once = added('json-gemini-tools', null); + expect(added('json-gemini-tools', once)).toBe(once); + }); + + it('returns a hand-formatted file untouched when the rule is already there', () => { + const input = '{"other":1,"tools":{"allowed":["run_shell_command(ailoud)"]}}'; + expect(added('json-gemini-tools', input)).toBe(input); + }); + + it('removes only our entry', () => { + const before = JSON.stringify({ + tools: { allowed: ['run_shell_command(git)', 'run_shell_command(ailoud)'] }, + }); + const out = removePermission('json-gemini-tools', before, CWD)!; + expect(parse(out).tools.allowed).toEqual(['run_shell_command(git)']); + expect(removePermission('json-gemini-tools', '{}', CWD)).toBeNull(); + }); +}); + +describe('jsonc-opencode-permission', () => { + it('allows both the bare command and the command with arguments', () => { + // opencode matches a command against a glob, and `ailoud *` does not + // match a bare `ailoud` -- which is what `ailoud audio ls` collapses to + // for an agent that runs the top-level alias. + const out = added('jsonc-opencode-permission', null); + expect(parse(out).permission.bash).toEqual({ ailoud: 'allow', 'ailoud *': 'allow' }); + }); + + it('keeps the mcp block a previous install wrote', () => { + const before = JSON.stringify({ mcp: { ailoud: { type: 'local' } } }); + const out = added('jsonc-opencode-permission', before); + expect(parse(out).mcp.ailoud.type).toBe('local'); + }); + + it('leaves a blanket permission setting alone', () => { + // `"permission": "ask"` applies to every tool. Expanding it into an + // object would silently drop that default for everything but bash. + expect(addPermission('jsonc-opencode-permission', '{"permission":"ask"}', CWD)).toEqual({ + ok: false, + reason: 'blanket', + }); + }); + + it('is idempotent, so a second install reports unchanged rather than a write', () => { + const once = added('jsonc-opencode-permission', null); + expect(added('jsonc-opencode-permission', once)).toBe(once); + }); + + it('returns a hand-formatted file untouched when the rule is already there', () => { + const input = '{"foo":"bar","permission":{"bash":{"ailoud":"allow","ailoud *":"allow"}}}'; + expect(added('jsonc-opencode-permission', input)).toBe(input); + }); + + it('removes both patterns and clears the emptied containers', () => { + const before = JSON.stringify({ + mcp: { ailoud: { type: 'local' } }, + permission: { bash: { ailoud: 'allow', 'ailoud *': 'allow' } }, + }); + const out = removePermission('jsonc-opencode-permission', before, CWD)!; + expect(parse(out).permission).toBeUndefined(); + expect(parse(out).mcp.ailoud.type).toBe('local'); + expect(removePermission('jsonc-opencode-permission', '{}', CWD)).toBeNull(); + }); + + it('empties a file left holding nothing but its own $schema', () => { + // opencode's file is created carrying `$schema` and nothing else when + // AILoud is all that is in it, so `$schema` alone is our scaffolding too + // and the caller deletes the file rather than writing back a husk. + const before = JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + permission: { bash: { ailoud: 'allow', 'ailoud *': 'allow' } }, + }); + expect(removePermission('jsonc-opencode-permission', before, CWD)).toBe(''); + }); + + it('does not disturb another tool sharing the bash map', () => { + const before = JSON.stringify({ + permission: { bash: { git: 'allow', ailoud: 'allow', 'ailoud *': 'allow' } }, + }); + const out = removePermission('jsonc-opencode-permission', before, CWD)!; + expect(parse(out).permission.bash).toEqual({ git: 'allow' }); + }); + + it('leaves a hand-written rule about our command exactly as it stands', () => { + // `"ailoud": "deny"` is not something an install ever wrote, so an + // uninstall that deleted it would hand back a privilege the user had + // taken away by hand. + const before = JSON.stringify({ + permission: { bash: { ailoud: 'deny', 'ailoud *': 'allow' } }, + }); + const out = removePermission('jsonc-opencode-permission', before, CWD)!; + expect(parse(out).permission.bash).toEqual({ ailoud: 'deny' }); + expect( + removePermission( + 'jsonc-opencode-permission', + JSON.stringify({ + permission: { bash: { ailoud: 'deny', 'ailoud *': 'ask' } }, + }), + CWD, + ), + ).toBeNull(); + }); + + it('adds the missing pattern when only one of the two is already present', () => { + // The early return in `addPermission` is gated on `hasPermission`, which + // must not answer true for a half-written rule. + const before = JSON.stringify({ permission: { bash: { ailoud: 'allow' } } }); + const out = added('jsonc-opencode-permission', before); + expect(parse(out).permission.bash).toEqual({ ailoud: 'allow', 'ailoud *': 'allow' }); + }); +}); + +describe('yaml-codex-policy', () => { + it('writes an allow list when there is no file', () => { + const out = added('yaml-codex-policy', null); + expect(parseDocument(out).toJSON().allow).toEqual(['ailoud', 'ailoud *']); + }); + + it('merges into an existing list instead of adding a second allow key', () => { + // Two `allow:` mappings in one document is a duplicate key, which is a + // YAML error -- the file stops loading and every rule in it is lost. + const before = '# Locksmith permissions\nallow:\n - "locksmith get *"\n'; + const out = added('yaml-codex-policy', before); + expect(out.match(/^allow:/gm)).toHaveLength(1); + expect(parseDocument(out).toJSON().allow).toEqual(['locksmith get *', 'ailoud', 'ailoud *']); + }); + + it('keeps the comments written inside the allow list', () => { + // The comments that are actually at risk are the ones attached to entries + // in the sequence: replacing the sequence node takes them with it, while + // a document-level comment survives that untouched. Asserting only on the + // latter is a test that passes whether or not this behaviour works. + const before = [ + '# header', + 'allow:', + ' # git tools, added by hand', + ' - "git status"', + ' - "locksmith get *" # secrets', + '', + ].join('\n'); + const out = added('yaml-codex-policy', before); + expect(out).toContain('# git tools, added by hand'); + expect(out).toContain('# secrets'); + expect(out).toContain('# header'); + expect(parseDocument(out).toJSON().allow).toEqual([ + 'git status', + 'locksmith get *', + 'ailoud', + 'ailoud *', + ]); + }); + + it('is idempotent', () => { + const once = added('yaml-codex-policy', null); + expect(added('yaml-codex-policy', once)).toBe(once); + expect(hasPermission('yaml-codex-policy', once, CWD)).toBe(true); + }); + + it('refuses a file it cannot parse and one whose allow is not a list', () => { + expect(addPermission('yaml-codex-policy', 'allow:\n - [unclosed', CWD)).toEqual({ + ok: false, + reason: 'unreadable', + }); + // Parses cleanly; it is the shape of `allow` we refuse to reinterpret, + // and calling that a YAML error would send the user hunting a typo. + expect(addPermission('yaml-codex-policy', 'allow: everything\n', CWD)).toEqual({ + ok: false, + reason: 'foreign', + }); + }); + + it('removes both patterns, and says so when there were none', () => { + const before = 'allow:\n - "locksmith get *"\n - "ailoud"\n - "ailoud *"\n'; + const out = removePermission('yaml-codex-policy', before, CWD)!; + expect(parseDocument(out).toJSON().allow).toEqual(['locksmith get *']); + expect(removePermission('yaml-codex-policy', 'allow:\n - "git"\n', CWD)).toBeNull(); + }); + + it('adds the missing pattern when only one of the two is already present', () => { + const before = 'allow:\n - "ailoud"\n'; + const out = added('yaml-codex-policy', before); + expect(parseDocument(out).toJSON().allow).toEqual(['ailoud', 'ailoud *']); + }); + + it('keeps the comments inside a list it only partly empties', () => { + const before = [ + 'allow:', + ' # git tools, added by hand', + ' - "git status"', + ' - "ailoud"', + ' - "ailoud *"', + '', + ].join('\n'); + const out = removePermission('yaml-codex-policy', before, CWD)!; + expect(out).toContain('# git tools, added by hand'); + expect(parseDocument(out).toJSON().allow).toEqual(['git status']); + }); + + it('keeps the heading above the key when the whole list goes', () => { + // A heading comment belongs to the key below it, not to the document, so + // deleting the key takes it away. An uninstall that quietly dropped the + // first line of somebody's policy file is not an uninstall. + const before = '# header\nallow:\n - "ailoud"\n - "ailoud *"\napproval_policy: on-request\n'; + expect(removePermission('yaml-codex-policy', before, CWD)).toBe( + '# header\napproval_policy: on-request\n', + ); + }); + + it('empties a policy file that held nothing but ours, rather than leaving {}', () => { + // The document API renders a mapping with no keys left as the literal + // `{}` -- a file that still records an install. The caller deletes a file + // that comes back empty. + const once = added('yaml-codex-policy', null); + expect(removePermission('yaml-codex-policy', once, CWD)).toBe(''); + }); + + it('keeps a comment the user wrote even when no key is left', () => { + // Their comment is not ours to delete, so the file survives holding it + // and nothing else. + expect(removePermission('yaml-codex-policy', '# my policy\nallow:\n - "ailoud"\n', CWD)).toBe( + '# my policy\n', + ); + }); +}); + +describe('json-copilot-locations', () => { + it('keys the approval by the directory it was granted for', () => { + // Copilot scopes shell approvals to a repository root, unlike its + // machine-wide MCP configuration. + const out = added('json-copilot-locations', null); + expect(parse(out).locations[CWD].tool_approvals).toEqual([ + { kind: 'commands', commandIdentifiers: ['ailoud:*'] }, + ]); + }); + + it('merges into the commands rule already there rather than adding a second', () => { + const before = JSON.stringify({ + locations: { + [CWD]: { tool_approvals: [{ kind: 'commands', commandIdentifiers: ['git status'] }] }, + }, + }); + const out = added('json-copilot-locations', before); + const approvals = parse(out).locations[CWD].tool_approvals; + expect(approvals).toHaveLength(1); + expect(approvals[0].commandIdentifiers).toEqual(['git status', 'ailoud:*']); + }); + + it('leaves another directory alone', () => { + const before = JSON.stringify({ + locations: { + '/other/repo': { tool_approvals: [{ kind: 'commands', commandIdentifiers: ['git'] }] }, + }, + }); + const out = added('json-copilot-locations', before); + expect(parse(out).locations['/other/repo'].tool_approvals[0].commandIdentifiers).toEqual([ + 'git', + ]); + expect(parse(out).locations[CWD].tool_approvals[0].commandIdentifiers).toEqual(['ailoud:*']); + }); + + it('is idempotent and reports presence per directory', () => { + const once = added('json-copilot-locations', null); + expect(added('json-copilot-locations', once)).toBe(once); + expect(hasPermission('json-copilot-locations', once, CWD)).toBe(true); + expect(hasPermission('json-copilot-locations', once, '/other/repo')).toBe(false); + }); + + it('removes our identifier and clears whatever that emptied', () => { + const before = JSON.stringify({ + mode: 'default', + locations: { [CWD]: { tool_approvals: [{ kind: 'commands', commandIdentifiers: [RULE] }] } }, + }); + const out = removePermission('json-copilot-locations', before, CWD)!; + expect(parse(out).locations).toBeUndefined(); + expect(parse(out).mode).toBe('default'); + expect(removePermission('json-copilot-locations', '{}', CWD)).toBeNull(); + }); + + it('empties a file that held approvals for this directory alone', () => { + // Copilot's file is machine-wide but partly agent-managed, so it is only + // ever deleted when nothing but our own grant was in it. + const once = added('json-copilot-locations', null); + expect(removePermission('json-copilot-locations', once, CWD)).toBe(''); + }); + + it('keeps the file when another directory still has approvals of its own', () => { + const before = JSON.stringify({ + locations: { + [CWD]: { tool_approvals: [{ kind: 'commands', commandIdentifiers: [RULE] }] }, + '/other/repo': { tool_approvals: [{ kind: 'commands', commandIdentifiers: ['git'] }] }, + }, + }); + const out = removePermission('json-copilot-locations', before, CWD)!; + expect(parse(out).locations[CWD]).toBeUndefined(); + expect(parse(out).locations['/other/repo'].tool_approvals[0].commandIdentifiers).toEqual([ + 'git', + ]); + }); +}); + +describe('describeRefusal', () => { + it('names the syntax the file is actually written in', () => { + // `policy.yaml` is never JSON, so "not valid JSON" sent a Codex user + // hunting for a problem their file could not have. + expect(describeRefusal('yaml-codex-policy', 'unreadable')).toContain('not valid YAML'); + expect(describeRefusal('json-claude-permissions', 'unreadable')).toContain('not valid JSON'); + }); + + it('does not call a file unparseable when parsing it was never the problem', () => { + // Both of these parse. One holds a blanket setting we will not expand, + // the other a shape we will not reinterpret. + for (const message of [ + describeRefusal('jsonc-opencode-permission', 'blanket'), + describeRefusal('yaml-codex-policy', 'foreign'), + ]) { + expect(message).not.toContain('not valid'); + expect(message).toContain('by hand'); + } + expect(describeRefusal('jsonc-opencode-permission', 'blanket')).toContain('every tool'); + }); +}); diff --git a/apps/cli/src/mcp/permissions.ts b/apps/cli/src/mcp/permissions.ts new file mode 100644 index 0000000..4bebcb0 --- /dev/null +++ b/apps/cli/src/mcp/permissions.ts @@ -0,0 +1,514 @@ +import { Scalar, isMap, isScalar, isSeq, parseDocument } from 'yaml'; +import type { Document } from 'yaml'; +import { tryParseJson } from './agentConfig.js'; + +/** + * How an agent's command allow-list is written. + * + * One per shape actually observed, as `ConfigFormat` is. Every path and every + * shape here was read from a working installation or an agent's published + * schema rather than from memory: an allow-list written into a key nothing + * reads looks exactly like a successful install, and the user finds out only + * when the approval prompt appears anyway. + */ +export type PermissionFormat = + | 'json-claude-permissions' + | 'yaml-codex-policy' + | 'jsonc-opencode-permission' + | 'json-gemini-tools' + | 'json-copilot-locations'; + +/** The command an agent is pre-approved to run. */ +const COMMAND = 'ailoud'; + +/** + * Claude Code's `Bash(...)` names Claude Code's own tool, not the user's + * shell -- the rule holds whether they run bash, zsh or fish. The `:*` form + * covers the bare command as well as any arguments. + */ +const CLAUDE_RULE = `Bash(${COMMAND}:*)`; + +/** Gemini matches on the command name, so one entry covers every subcommand. */ +const GEMINI_RULE = `run_shell_command(${COMMAND})`; + +/** + * Two patterns, because opencode matches a command against a glob and + * `ailoud *` does not match a bare `ailoud` with no arguments. + */ +const GLOB_RULES = [COMMAND, `${COMMAND} *`] as const; + +/** Copilot matches `name:*` against the command and its arguments alike. */ +const COPILOT_RULE = `${COMMAND}:*`; + +/** + * The heading written above the allow list in a policy file AILoud created. + * + * Named rather than repeated so the uninstall can recognise its own heading + * and take it away again; every other comment in that file is the user's. + */ +const CODEX_HEADER = 'AILoud permissions'; + +type Json = Record; + +/** + * Why an allow-list was left alone. + * + * Three separate problems, and what the user should do about each differs, + * so the caller cannot report them with one sentence. It used to: every + * refusal printed "not valid JSON", which sent a Codex user hunting a JSON + * error in a YAML file and an opencode user hunting a syntax error in a file + * whose syntax was fine. + */ +export type PermissionRefusal = + /** The file is there and does not parse. */ + | 'unreadable' + /** opencode's `"permission": "ask"` -- one default covering every tool. */ + | 'blanket' + /** A key we would write into holds a shape we will not reinterpret. */ + | 'foreign'; + +/** The file as it should now read, or why there is no such text. */ +export type PermissionEdit = + | { readonly ok: true; readonly text: string } + | { readonly ok: false; readonly reason: PermissionRefusal }; + +const edited = (text: string): PermissionEdit => ({ ok: true, text }); +const refused = (reason: PermissionRefusal): PermissionEdit => ({ ok: false, reason }); + +/** + * Adds our entry, or says why the file cannot be edited safely. + * + * A refusal rather than a throw: the allow-list is a convenience on top of an + * install, and failing the whole install -- after the MCP configuration and + * the rules block were already written -- because one settings file has a + * stray comma would be a worse outcome than saying so and moving on. + */ +export function addPermission( + format: PermissionFormat, + previous: string | null, + cwd: string, +): PermissionEdit { + // Checked before any parse-and-reserialise: a hand-formatted file that + // already carries the rule must come back untouched, not reformatted to + // this module's own JSON.stringify style. `editJson`'s byte-identical + // return only works when `previous` was itself produced by `print()`. + if (previous !== null && hasPermission(format, previous, cwd)) return edited(previous); + switch (format) { + case 'json-claude-permissions': + return editJson(previous, (root) => { + const permissions = objectAt(root, 'permissions'); + const allow = stringsAt(permissions, 'allow'); + if (allow === null) return 'foreign'; + if (!allow.includes(CLAUDE_RULE)) allow.push(CLAUDE_RULE); + permissions['allow'] = allow; + return null; + }); + case 'json-gemini-tools': + return editJson(previous, (root) => { + const tools = objectAt(root, 'tools'); + const allowed = stringsAt(tools, 'allowed'); + if (allowed === null) return 'foreign'; + if (!allowed.includes(GEMINI_RULE)) allowed.push(GEMINI_RULE); + tools['allowed'] = allowed; + return null; + }); + case 'jsonc-opencode-permission': + return editJson(previous, (root) => { + // A blanket `"permission": "ask"` applies to every tool. Expanding it + // into an object would drop that default for everything but bash. + const current = root['permission']; + if (current !== undefined && (typeof current !== 'object' || current === null)) { + return 'blanket'; + } + const permission = objectAt(root, 'permission'); + const bash = objectAt(permission, 'bash'); + for (const pattern of GLOB_RULES) bash[pattern] = 'allow'; + return null; + }); + case 'yaml-codex-policy': + return addCodexPolicy(previous); + case 'json-copilot-locations': + return editJson(previous, (root) => { + const locations = objectAt(root, 'locations'); + const location = objectAt(locations, cwd); + const approvals = location['tool_approvals']; + if (approvals !== undefined && !Array.isArray(approvals)) return 'foreign'; + const list = Array.isArray(approvals) ? [...approvals] : []; + const commands = list.find( + (entry): entry is Json => + entry !== null && typeof entry === 'object' && (entry as Json)['kind'] === 'commands', + ); + if (commands === undefined) { + list.push({ kind: 'commands', commandIdentifiers: [COPILOT_RULE] }); + } else { + const ids = stringsAt(commands, 'commandIdentifiers'); + if (ids === null) return 'foreign'; + if (!ids.includes(COPILOT_RULE)) ids.push(COPILOT_RULE); + commands['commandIdentifiers'] = ids; + } + location['tool_approvals'] = list; + return null; + }); + } +} + +/** + * The refusal as a sentence, naming the format's own syntax. + * + * Here rather than in the command, because which of these files is JSON and + * which is YAML is this module's knowledge; the command only prints it. + */ +export function describeRefusal(format: PermissionFormat, reason: PermissionRefusal): string { + const byHand = 'left alone -- add the entry by hand'; + switch (reason) { + case 'unreadable': + return `not valid ${format === 'yaml-codex-policy' ? 'YAML' : 'JSON'}; ${byHand}`; + case 'blanket': + return `"permission" is one blanket setting for every tool; ${byHand}`; + case 'foreign': + return `the allow-list key holds something unexpected; ${byHand}`; + } +} + +/** + * Removes our entry, or null when there was nothing of ours to remove. + * + * The empty string is a third answer: the file held nothing but what an + * install put there, and the caller deletes it rather than writing back a + * husk. `removeTomlTable` in `agentConfig.ts` says the same thing the same + * way. + */ +export function removePermission( + format: PermissionFormat, + previous: string, + cwd: string, +): string | null { + switch (format) { + case 'json-claude-permissions': + return dropFromList(previous, 'permissions', 'allow', CLAUDE_RULE); + case 'json-gemini-tools': + return dropFromList(previous, 'tools', 'allowed', GEMINI_RULE); + case 'jsonc-opencode-permission': { + const root = tryParseJson(previous); + if (root === null) return null; + const permission = root['permission']; + if (permission === null || typeof permission !== 'object') return null; + const bash = (permission as Json)['bash']; + if (bash === null || typeof bash !== 'object') return null; + const map = bash as Json; + // Only the value an install would have written. A key that says + // anything else -- `"ailoud": "deny"` -- is the user's own decision + // about our command, and deleting it on the way out would silently + // re-open a door they had shut. + const had = GLOB_RULES.filter((pattern) => map[pattern] === 'allow'); + if (had.length === 0) return null; + for (const pattern of had) delete map[pattern]; + if (Object.keys(map).length === 0) delete (permission as Json)['bash']; + if (Object.keys(permission as Json).length === 0) delete root['permission']; + return printOrEmpty(root); + } + case 'yaml-codex-policy': + return removeCodexPolicy(previous); + case 'json-copilot-locations': { + const root = tryParseJson(previous); + if (root === null) return null; + const locations = root['locations']; + if (locations === null || typeof locations !== 'object') return null; + const location = (locations as Json)[cwd]; + if (location === null || typeof location !== 'object') return null; + const list = (location as Json)['tool_approvals']; + if (!Array.isArray(list)) return null; + let removed = false; + const kept = list.filter((entry) => { + if (entry === null || typeof entry !== 'object') return true; + const rule = entry as Json; + if (rule['kind'] !== 'commands') return true; + const ids = Array.isArray(rule['commandIdentifiers']) ? rule['commandIdentifiers'] : []; + if (!ids.includes(COPILOT_RULE)) return true; + removed = true; + const left = ids.filter((id) => id !== COPILOT_RULE); + rule['commandIdentifiers'] = left; + return left.length > 0; + }); + if (!removed) return null; + if (kept.length === 0) delete (location as Json)['tool_approvals']; + else (location as Json)['tool_approvals'] = kept; + if (Object.keys(location as Json).length === 0) delete (locations as Json)[cwd]; + if (Object.keys(locations as Json).length === 0) delete root['locations']; + return printOrEmpty(root); + } + } +} + +/** Whether our entry is already there. */ +export function hasPermission(format: PermissionFormat, text: string, cwd: string): boolean { + if (format === 'yaml-codex-policy') { + const current = codexAllowList(text); + return current !== null && GLOB_RULES.every((pattern) => current.includes(pattern)); + } + const root = tryParseJson(text); + if (root === null) return false; + switch (format) { + case 'json-claude-permissions': + return listAt(root, 'permissions', 'allow').includes(CLAUDE_RULE); + case 'json-gemini-tools': + return listAt(root, 'tools', 'allowed').includes(GEMINI_RULE); + case 'jsonc-opencode-permission': { + const permission = root['permission']; + if (permission === null || typeof permission !== 'object') return false; + const bash = (permission as Json)['bash']; + if (bash === null || typeof bash !== 'object') return false; + return GLOB_RULES.every((pattern) => (bash as Json)[pattern] === 'allow'); + } + case 'json-copilot-locations': { + const locations = root['locations']; + if (locations === null || typeof locations !== 'object') return false; + const location = (locations as Json)[cwd]; + if (location === null || typeof location !== 'object') return false; + const list = (location as Json)['tool_approvals']; + if (!Array.isArray(list)) return false; + return list.some( + (entry) => + entry !== null && + typeof entry === 'object' && + (entry as Json)['kind'] === 'commands' && + Array.isArray((entry as Json)['commandIdentifiers']) && + ((entry as Json)['commandIdentifiers'] as unknown[]).includes(COPILOT_RULE), + ); + } + } +} + +// --- JSON ------------------------------------------------------------------ + +function print(root: Json): string { + return `${JSON.stringify(root, null, 2)}\n`; +} + +/** + * The document, or the empty string when nothing of the user's is left in it. + * + * Used only by the removal paths, so the caller can delete a file rather than + * write back `{}` -- which records that an install once happened, which is + * what the uninstall was asked to undo. `$schema` counts as ours as well, + * for the same reason `isEmptyConfig` says so: opencode's file is created + * carrying it and nothing else when AILoud is all that is in it. + */ +function printOrEmpty(root: Json): string { + const keys = Object.keys(root).filter((key) => key !== '$schema'); + return keys.length === 0 ? '' : print(root); +} + +/** + * Parses, mutates, re-serialises. The mutation names a refusal to abandon the + * edit, or null to keep it -- for a file whose shape we would have to + * reinterpret to write into. + */ +function editJson( + previous: string | null, + mutate: (root: Json) => PermissionRefusal | null, +): PermissionEdit { + const root = previous === null ? {} : tryParseJson(previous); + if (root === null) return refused('unreadable'); + const refusal = mutate(root); + if (refusal !== null) return refused(refusal); + const out = print(root); + // Byte-identical when nothing was missing, so the caller reports + // `unchanged` instead of claiming a write it did not make. + return edited(previous !== null && out === previous ? previous : out); +} + +function objectAt(root: Json, key: string): Json { + const found = root[key]; + if (found !== null && typeof found === 'object' && !Array.isArray(found)) return found as Json; + const fresh: Json = {}; + root[key] = fresh; + return fresh; +} + +/** The string array at `key`, or null when something else is sitting there. */ +function stringsAt(root: Json, key: string): string[] | null { + const found = root[key]; + if (found === undefined) return []; + if (!Array.isArray(found)) return null; + if (!found.every((entry) => typeof entry === 'string')) return null; + return [...(found as string[])]; +} + +function listAt(root: Json, container: string, key: string): readonly string[] { + const found = root[container]; + if (found === null || typeof found !== 'object') return []; + const list = (found as Json)[key]; + return Array.isArray(list) ? (list.filter((e) => typeof e === 'string') as string[]) : []; +} + +function dropFromList( + previous: string, + container: string, + key: string, + rule: string, +): string | null { + const root = tryParseJson(previous); + if (root === null) return null; + const found = root[container]; + if (found === null || typeof found !== 'object') return null; + const holder = found as Json; + const list = holder[key]; + if (!Array.isArray(list) || !list.includes(rule)) return null; + const kept = list.filter((entry) => entry !== rule); + if (kept.length === 0) delete holder[key]; + else holder[key] = kept; + if (Object.keys(holder).length === 0) delete root[container]; + return printOrEmpty(root); +} + +// --- YAML ------------------------------------------------------------------ + +/** + * The `allow` sequence as plain strings, or null when the document cannot be + * read or `allow` is holding something that is not a list of strings. + */ +function codexAllowList(text: string): string[] | null { + const doc = codexDocument(text); + return doc === null ? null : allowStrings(doc); +} + +/** The parsed policy file, or null when it is not readable YAML at all. */ +function codexDocument(text: string): Document | null { + try { + const doc = parseDocument(text); + return doc.errors.length > 0 ? null : doc; + } catch { + return null; + } +} + +/** Split from the parse so a refusal can say which of the two went wrong. */ +function allowStrings(doc: Document): string[] | null { + const listed = doc.get('allow'); + if (listed === undefined || listed === null) return []; + const asJson = (listed as { toJSON?: () => unknown }).toJSON?.(); + if (!Array.isArray(asJson)) return null; + if (!asJson.every((entry) => typeof entry === 'string')) return null; + return asJson as string[]; +} + +/** + * Codex, through the yaml document API, which preserves comments and key + * order -- the same reason `agentConfig.ts` uses it for Hermes. + * + * Merged into the existing sequence rather than appended as a second + * `allow:` mapping: a duplicate key is a YAML error, and the file it breaks + * is the one holding every command the user has already approved. + */ +function addCodexPolicy(previous: string | null): PermissionEdit { + if (previous === null || previous.trim() === '') { + // Emitted directly rather than through the document API, which renders + // everything built from an empty seed in flow style -- valid YAML that no + // hand-written policy file looks like. + return edited( + [ + `# ${CODEX_HEADER}`, + 'allow:', + ...GLOB_RULES.map((r) => ` - ${JSON.stringify(r)}`), + '', + ].join('\n'), + ); + } + const doc = codexDocument(previous); + if (doc === null) return refused('unreadable'); + const current = allowStrings(doc); + if (current === null) return refused('foreign'); + const missing = GLOB_RULES.filter((pattern) => !current.includes(pattern)); + if (missing.length === 0) return edited(previous); + const listed = doc.get('allow', true); + if (isSeq(listed)) { + // Appended to the sequence node in place. `doc.set('allow', [...])` + // replaces the whole node instead, and every comment written inside the + // list goes with it -- the line saying why a hand-added command is + // trusted, which is the one thing in that file worth keeping. + for (const pattern of missing) listed.add(quoted(pattern)); + } else { + // No `allow` key yet, or one holding null: there is no sequence to merge + // into, so the key is created. + doc.set('allow', missing.map(quoted)); + } + return edited(doc.toString()); +} + +/** + * A double-quoted scalar, matching the entries written into a fresh file. + * + * A bare `- ailoud *` is valid YAML and parses the same, but a policy file + * whose quoting changes halfway down its own allow list reads as if it had + * been corrupted. + */ +function quoted(value: string): Scalar { + const node = new Scalar(value); + node.type = Scalar.QUOTE_DOUBLE; + return node; +} + +/** + * Codex, through the same document API and for the same reason. + * + * Entries are spliced out of the sequence that is already there rather than + * the key being rewritten or deleted outright: `doc.set` replaces the whole + * sequence and drops every comment inside it, and `doc.delete('allow')` drops + * the comment sitting above the key -- which is where a policy file's header + * lives, so a silent uninstall took the user's own heading with it. + */ +function removeCodexPolicy(previous: string): string | null { + const current = codexAllowList(previous); + if (current === null) return null; + if (!current.some((entry) => isOurs(entry))) return null; + const doc = parseDocument(previous); + const listed = doc.get('allow', true); + if (!isSeq(listed)) return null; + const kept = listed.items.filter((item) => !(isScalar(item) && isOurs(String(item.value)))); + if (kept.length > 0) { + listed.items = kept; + return doc.toString(); + } + return withoutAllowKey(doc); +} + +function isOurs(entry: string): boolean { + return (GLOB_RULES as readonly string[]).includes(entry); +} + +/** + * The document without its `allow` key, as text. + * + * Two things `doc.delete('allow')` gets wrong on its own. It loses the + * comment above the key: ours is the header we wrote, but a hand-written + * file's is the user's, so that one moves down to the next key instead of + * being dropped. And with no key left it renders the mapping as the literal + * `{}` -- a file that still records an install, which is the thing the + * uninstall was asked to undo. Nothing left at all comes back as the empty + * string, and the caller deletes the file; comments the user wrote come back + * on their own, because those are not ours to remove. + */ +function withoutAllowKey(doc: Document): string { + const map = doc.contents; + if (!isMap(map)) return ''; + const at = map.items.findIndex((pair) => isScalar(pair.key) && pair.key.value === 'allow'); + if (at === -1) return doc.toString(); + const [pair] = map.items.splice(at, 1); + let orphan = isScalar(pair?.key) ? (pair.key.commentBefore ?? null) : null; + if (orphan !== null && orphan.trim() === CODEX_HEADER) orphan = null; + const next = map.items[at]; + if (orphan !== null && next !== undefined && isScalar(next.key)) { + next.key.commentBefore = + next.key.commentBefore === null || next.key.commentBefore === undefined + ? orphan + : `${orphan}\n${next.key.commentBefore}`; + orphan = null; + } + if (map.items.length > 0) return doc.toString(); + const lines = [doc.commentBefore, orphan, doc.comment] + .filter((comment): comment is string => typeof comment === 'string' && comment !== '') + .flatMap((comment) => comment.split('\n')) + .map((line) => `#${line}`); + return lines.length === 0 ? '' : `${lines.join('\n')}\n`; +} diff --git a/apps/cli/src/mcp/rulesBlock.test.ts b/apps/cli/src/mcp/rulesBlock.test.ts index cd07a35..de2ca26 100644 --- a/apps/cli/src/mcp/rulesBlock.test.ts +++ b/apps/cli/src/mcp/rulesBlock.test.ts @@ -20,7 +20,41 @@ describe('rulesBlock', () => { expect(rulesBlock()).toMatch(/ailoud audio search/); }); + it('tells the agent to ask about speakers and languages', () => { + expect(rulesBlock()).toContain('how many people speak'); + }); + + it('tells the agent to poll rather than wait', () => { + expect(rulesBlock()).toContain('job_status'); + // The prompt to name speakers is the one thing an agent cannot infer: + // only a person knows which label is which. + expect(rulesBlock()).toContain('unnamedSpeakers'); + expect(rulesBlock()).toContain('annotate'); + }); + + it('tells the agent to prefer MCP tools, since only they enforce the check', () => { + expect(rulesBlock()).toMatch(/MCP tools\*\* \(prefer/); + }); + + it('tells the agent not to ask about cpu or gpu settings', () => { + // An agent that asks "what should I set --max-cpu to?" spends a turn on a + // question whose answer changes nothing: the default is already right. + expect(rulesBlock()).toMatch(/not ask about CPU or GPU/i); + }); + + it('points at doctor rather than explaining the numbers here', () => { + // The room for the reasoning is SERVER_INSTRUCTIONS and docs/, per this + // file's own ceiling test below. + expect(rulesBlock()).toContain('doctor'); + }); + + it('never names the per-run flag in the rules block', () => { + expect(rulesBlock()).not.toContain('--max-cpu'); + }); + it('stays short, since it shares a file with the project instructions', () => { + // A ceiling, not a measurement. If a change needs more room than this, + // the room belongs in SERVER_INSTRUCTIONS or in docs/, not here. expect(rulesBlock().split('\n').length).toBeLessThan(30); }); }); diff --git a/apps/cli/src/mcp/rulesBlock.ts b/apps/cli/src/mcp/rulesBlock.ts index c32297e..2b310b3 100644 --- a/apps/cli/src/mcp/rulesBlock.ts +++ b/apps/cli/src/mcp/rulesBlock.ts @@ -1,3 +1,10 @@ +import { + blockRange as rangeIn, + hasBlock as hasIn, + withBlock as withIn, + withoutBlock as withoutIn, +} from '../markerBlock.js'; + /** * The block AILoud writes into an agent's rules file. * @@ -9,13 +16,17 @@ export const START = ''; export const END = ''; +/** The marker pair a rules file uses. Shell startup files use their own. */ +const MARKERS = { start: START, end: END }; + /** * What the agent is told. * * Short on purpose. This lands in a file that already carries the project's - * own instructions, and a long block competes with them. It says the three - * things an agent gets wrong without being told: search instead of reading, - * transcripts arrive as files, and tag what is untagged. + * own instructions, and a long block competes with them. It says the things + * an agent gets wrong without being told: search instead of reading, + * transcripts arrive as files, tag what is untagged, declare speakers and + * languages before transcribing, and poll job_status instead of waiting. */ export function rulesBlock(): string { return [ @@ -25,12 +36,12 @@ export function rulesBlock(): string { 'For questions about recordings, meetings, calls or transcripts, use AILoud rather than', 'reading media or transcript files yourself:', '', - '- **MCP tools** (when available): `search_transcripts` finds where something was said and', - ' returns the matching lines with timestamps and speakers -- reach for it BEFORE', - ' `get_transcript`, which returns a file path rather than text precisely because a whole', + '- **MCP tools** (prefer -- only these enforce the check below): `search_transcripts` finds where', + ' something was said and returns the matching lines with timestamps and speakers -- reach for it', + ' BEFORE `get_transcript`, which returns a file path rather than text precisely because a whole', ' transcript costs thousands of tokens. `list_recordings` orients you; `summarize` writes a', ' report, and `list_templates` first, because the headings differ by kind of conversation.', - '- **Shell** (always works): `ailoud audio search ""`, `ailoud audio ls`,', + '- **Shell** (fallback -- skips it): `ailoud audio search ""`, `ailoud audio ls`,', ' `ailoud audio summarize --template `.', '', 'Tag recordings as you go (`--tag`, or `annotate`). Tags are the only way to ask for "the', @@ -38,65 +49,35 @@ export function rulesBlock(): string { '', 'Summaries take a short `context` -- who these people are, what the project is. AILoud does', 'not remember it between calls, so keep it and pass it again.', + '', + 'Before `transcribe`, ask the user how many people speak and in which languages, and offer', + "your own guess from the recording's name. It decides the transcript's quality.", + '', + '`transcribe` and `summarize` return a job id: poll `job_status`, a few minutes apart. When', + 'it reports `unnamedSpeakers`, ask who they are and save it with `annotate`; names outlive re-runs.', + '', + 'Do not ask about CPU or GPU settings: the defaults are right. Speed comes from the build', + 'having a GPU; without one, more threads help (`ailoud doctor` reports which).', END, ].join('\n'); } -/** - * Where our block sits, or null. - * - * The START taken is the LAST one before the first END, not the first one in - * the file. Pairing the first START with the first END destroyed user text: - * a rules file that merely MENTIONS the marker -- "we wrap our rules in - * markers" -- made the range run from that sentence to - * the end of our real block, and everything in between was replaced or - * deleted. - */ +/** Where our block sits, or null. The pairing rule that matters lives in markerBlock.ts. */ export function blockRange(text: string): { readonly from: number; readonly to: number } | null { - const end = text.indexOf(END); - if (end === -1) return null; - const from = text.lastIndexOf(START, end); - if (from === -1) return null; - return { from, to: end + END.length }; + return rangeIn(text, MARKERS); } /** Whether a rules file already carries our block. */ export function hasBlock(text: string): boolean { - return blockRange(text) !== null; + return hasIn(text, MARKERS); } -/** - * Inserts or replaces the block, returning the whole file. - * - * Appended with one blank line before it when absent, which is what makes the - * result stable: running install twice produces the same bytes as running it - * once, so `update` is safe to run on a schedule and a diff after it shows - * only what actually changed. - */ +/** Inserts or replaces the block, returning the whole file. Why one blank line is what makes this idempotent lives in markerBlock.ts. */ export function withBlock(text: string, block = rulesBlock()): string { - const range = blockRange(text); - if (range === null) { - const base = text.trimEnd(); - return base === '' ? `${block}\n` : `${base}\n\n${block}\n`; - } - return `${text.slice(0, range.from)}${block}${text.slice(range.to)}`; + return withIn(text, block, MARKERS); } -/** - * Removes the block, returning the whole file, or null when there was none. - * - * Null rather than the unchanged text so a caller can tell "removed" from - * "there was nothing of ours here" and report the difference -- an uninstall - * that claims to have cleaned a file it never touched teaches the user to - * distrust it. - */ +/** Removes the block, returning the whole file, or null when there was none. Why null and not the unchanged text lives in markerBlock.ts. */ export function withoutBlock(text: string): string | null { - const range = blockRange(text); - if (range === null) return null; - const before = text.slice(0, range.from).replace(/\n+$/, ''); - const after = text.slice(range.to).replace(/^\n+/, ''); - if (before === '' && after === '') return ''; - if (before === '') return `${after.trimEnd()}\n`; - if (after === '') return `${before}\n`; - return `${before}\n\n${after.trimEnd()}\n`; + return withoutIn(text, MARKERS); } diff --git a/apps/cli/src/mcp/server.test.ts b/apps/cli/src/mcp/server.test.ts index 99734c0..a0bba32 100644 --- a/apps/cli/src/mcp/server.test.ts +++ b/apps/cli/src/mcp/server.test.ts @@ -1,13 +1,40 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import type { MemFs } from '@ailoud/core/testing'; -import { contextWithTranscript } from '../commands/testContext.js'; +import { context, contextWithTranscript, withRealDataDir } from '../commands/testContext.js'; +import { buildProgram } from '../program.js'; +import { createJob, getJob, listJobs } from '../jobs/store.js'; +import { withJobLock } from '../jobs/lock.js'; +import { spawnDetachedJob } from '../jobs/spawn.js'; +import { writeJobState } from '../jobs/state.js'; import { buildMcpServer } from './server.js'; import { SERVER_INSTRUCTIONS } from './instructions.js'; +// Every transcribe/summarize call under test would otherwise spawn a real +// node process (see spawn.ts's own doc comment on why `cliEntryPath` +// resolves to a build that does not exist under a test runner). Mocked at +// the module boundary, the same way the CLI's own --detach tests mock it +// (apps/cli/src/commands/commands.test.ts, summarize.test.ts), so what is +// under test is this tool building the right job and the right argv, not a +// child process actually running. +vi.mock('../jobs/spawn.js', () => ({ spawnDetachedJob: vi.fn() })); + type Ctx = Awaited>; +/** + * Imports `path` -- writing its (fake) content into the in-memory fs first, + * since nothing has put it there yet -- through the real `import` command, + * so the resulting recording has a genuine mediaPath a later `transcribe` + * call can actually read. Returns the new recording's id. + */ +async function importFixture(ctx: ReturnType, path: string): Promise { + (ctx.fs as MemFs).files.set(path, 'AUDIO'); + await buildProgram(ctx).parseAsync(['node', 'ailoud', 'import', path]); + const recordings = await ctx.store.listRecordings({}); + return recordings[recordings.length - 1]!.id; +} + /** A real client over an in-memory transport: the wiring is exercised, not mocked. */ async function connect(context: Ctx) { const { server, close } = buildMcpServer(context, '9.9.9'); @@ -28,6 +55,10 @@ async function connect(context: Ctx) { return { client, call, close }; } +afterEach(() => { + vi.mocked(spawnDetachedJob).mockReset(); +}); + describe('the MCP surface', () => { it('offers a tool for every job an agent needs, and no more', async () => { const ctx = await contextWithTranscript({ clearLines: true }); @@ -42,6 +73,7 @@ describe('the MCP surface', () => { 'get_report', 'get_transcript', 'import_recording', + 'job_status', 'list_recordings', 'list_reports', 'list_speakers', @@ -86,6 +118,7 @@ describe('the MCP surface', () => { const { client, close } = await connect(ctx); const byName = new Map((await client.listTools()).tools.map((t) => [t.name, t])); expect(byName.get('search_transcripts')?.annotations?.readOnlyHint).toBe(true); + expect(byName.get('job_status')?.annotations?.readOnlyHint).toBe(true); expect(byName.get('delete_recording')?.annotations?.destructiveHint).toBe(true); expect(byName.get('delete_report')?.annotations?.destructiveHint).toBe(true); await close(); @@ -210,24 +243,82 @@ describe('MCP: refusals are marked as failures', () => { }); }); -describe('MCP: summarising', () => { - it('uses the template and the caller context it was given, and saves the report', async () => { +describe('MCP: summarize starts a background job', () => { + // The actual summarising -- template applied, context passed to the + // model, the report saved -- now happens in the spawned child, which runs + // the same `ailoud summarize` pipeline the CLI does (and is covered by + // that command's own tests). What this tool owns, and what is under test + // here, is building the right job and the right child argv from the + // template and context it was given. + it('passes the template and caller context through to the child argv', async () => { const ctx = await contextWithTranscript({ clearLines: true }); - const { call, close } = await connect(ctx); - const body = ( - await call('summarize', { - recordingIds: ['ID001'], - template: 'one-on-one', - context: 'Ann is the manager.', - }) - ).json(); - expect(body['template']).toBe('one-on-one'); - expect(ctx.summarizerPrompts[0]).toContain('Concerns raised'); - expect(ctx.summarizerPrompts[0]).toContain('Ann is the manager.'); - const stored = await ctx.store.listSummaries('ID001'); - expect(stored[0]!.template).toBe('one-on-one'); - expect(stored[0]!.context).toBe('Ann is the manager.'); - await close(); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + const body = ( + await call('summarize', { + recordingIds: ['ID001'], + template: 'one-on-one', + context: 'Ann is the manager.', + }) + ).json(); + expect(body['jobId']).toBeDefined(); + expect(body['kind']).toBe('summarize'); + expect(body['summary']).toBeUndefined(); + expect(vi.mocked(spawnDetachedJob)).toHaveBeenCalledTimes(1); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual([ + 'summarize', + 'ID001', + '--template', + 'one-on-one', + '--context', + 'Ann is the manager.', + ]); + await close(); + }); + }); + + it('returns a job id rather than the summary body', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + const body = (await call('summarize', { recordingIds: ['ID001'] })).json(); + expect(body['jobId']).toBeDefined(); + expect(body['summary']).toBeUndefined(); + expect(body['reportId']).toBeUndefined(); + await close(); + }); + }); + + it('refuses synchronously when a job already holds the lock', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + await withJobLock(ctx.paths.dataDir, async () => { + const { call, close } = await connect(ctx); + const result = await call('summarize', { recordingIds: ['ID001'] }); + expect(result.isError).toBe(true); + expect(result.json()['error']).toContain('already running'); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + await close(); + }); + }); + }); + + it('marks the job failed and reports it when spawning itself throws', async () => { + const ctx = await contextWithTranscript({ clearLines: true }); + await withRealDataDir(ctx, async () => { + vi.mocked(spawnDetachedJob).mockImplementation(() => { + throw new Error('spawn boom'); + }); + const { call, close } = await connect(ctx); + const result = await call('summarize', { recordingIds: ['ID001'] }); + expect(result.isError).toBe(true); + const jobId = result.json()['jobId'] as string; + const state = await getJob(ctx.fs, ctx.paths.jobsDir, jobId); + expect(state?.state).toBe('failed'); + expect(state?.error).toContain('spawn boom'); + await close(); + }); }); }); @@ -365,39 +456,478 @@ describe('MCP: the run directory and its file names', () => { }); describe('MCP: one summarisation pipeline, shared with the CLI', () => { - it('never re-summarises a single recording from its own stored report', async () => { - // The rule that drifted while the pipeline was written twice: fixed in - // the CLI command, and it had to be remembered separately here. + // Before this task, this tool called runSummary itself -- a second call + // site the CLI's own copy could drift from (see the CLI's own + // resolveSummarizeRun doc comment). Converting this tool to spawn the same + // `ailoud summarize` the CLI runs removes the second call site entirely: + // there is now exactly one place that reuses stored reports or refuses to + // re-summarise a recording from its own report, and it is covered by that + // command's own tests (apps/cli/src/commands/summarize.test.ts). What + // remains to check here is that a group of ids reaches the child argv + // untouched, in the order given. + it('passes every id in a group through to the child, in order', async () => { const ctx = await contextWithTranscript({ clearLines: true }); + const first = (await ctx.store.listRecordings({}))[0]!; + await ctx.store.insertRecording({ ...first, id: 'ID002', sha256: 'other' }); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + await call('summarize', { recordingIds: ['ID001', 'ID002'] }); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toEqual(['summarize', 'ID001', 'ID002']); + await close(); + }); + }); +}); + +describe('MCP: transcribe refuses until speakers and languages are declared', () => { + it('refuses without a speaker count or languages, and offers a guess', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/2026-08-14-standup-ru-en.m4a'); const { call, close } = await connect(ctx); - await call('summarize', { recordingIds: ['ID001'] }); - ctx.summarizerPrompts.length = 0; - await call('summarize', { recordingIds: ['ID001'], language: 'ru' }); - expect(ctx.summarizerPrompts[0]).toContain('Privet.'); - expect(ctx.summarizerPrompts[0]).not.toMatch(/earlier summary/i); + const result = await call('transcribe', { recordingIds: [id] }); + expect(result.isError).toBe(true); + const body = result.json(); + expect(body['error']).toContain('speaker count'); + const guess = body['guess'] as { languages: string[]; from: string }; + expect(guess.languages).toEqual(['ru', 'en']); + expect(guess.from).toContain('2026-08-14-standup-ru-en.m4a'); + expect(body['ask']).toContain('Ask the user'); await close(); }); - it('reuses stored reports for a group, where they pay', async () => { - const ctx = await contextWithTranscript({ clearLines: true }); - const first = (await ctx.store.listRecordings({}))[0]!; - await ctx.store.insertRecording({ ...first, id: 'ID002', sha256: 'other' }); - for (const id of ['ID001', 'ID002']) { - await ctx.store.insertSummary({ - id: `SUM-${id}`, - createdAt: '2026-08-31T00:00:00.000Z', - language: 'en', - provider: 'fake', - model: 'fake-model', - body: `summary of ${id}`, - template: 'meeting', - context: '', + it('refuses with a null guess when the name says nothing', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + const { call, close } = await connect(ctx); + const result = await call('transcribe', { recordingIds: [id] }); + expect(result.isError).toBe(true); + const body = result.json(); + // Null, never a fabrication: a guess invented from nothing gets confirmed + // by a user who is skimming. + expect(body['guess']).toBeNull(); + await close(); + }); + + it('refuses when only one of the two is given', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + const { call, close } = await connect(ctx); + for (const args of [{ speakers: 3 }, { languages: ['ru'] }]) { + const result = await call('transcribe', { recordingIds: [id], ...args }); + expect(result.isError).toBe(true); + } + await close(); + }); + + it('accepts the explicit not-knowns', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 'unknown', + languages: ['auto'], + }); + expect(result.isError).toBe(false); + expect(result.json()['jobId']).toBeDefined(); + await close(); + }); + }); + + // The diarizer itself no longer runs inside this call -- it runs in the + // spawned child, which is mocked here (see the module-level vi.mock) and + // covered by the CLI's own transcribe tests. What these two check is that + // the child's argv reflects the same rule the inline pipeline used to + // apply directly: --speakers only informs the diarizer, and only when a + // real count was declared alongside --diarize. + it('passes a declared speaker count to the child only when diarize is on', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + await call('transcribe', { + recordingIds: [id], + speakers: 3, + languages: ['en'], + diarize: true, + }); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toContain('--diarize'); + expect(commandArgs).toEqual(expect.arrayContaining(['--speakers', '3'])); + await close(); + }); + }); + + it('omits --speakers from the child argv when the count is unknown, even with diarize on', async () => { + // Distinct from the sibling test above: this one holds diarize === true + // fixed and varies only whether speakers is a number, so it actually + // exercises the `typeof speakers === 'number'` half of the guard rather + // than the `diarize === true` half. + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + await call('transcribe', { recordingIds: [id], + speakers: 'unknown', + languages: ['en'], + diarize: true, + }); + const [, commandArgs] = vi.mocked(spawnDetachedJob).mock.calls[0]!; + expect(commandArgs).toContain('--diarize'); + expect(commandArgs).not.toContain('--speakers'); + await close(); + }); + }); + + it('refuses "auto" mixed with a real language', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 'unknown', + languages: ['auto', 'en'], + }); + expect(result.isError).toBe(true); + expect(result.raw).toContain('auto'); + await close(); + }); + + it('refuses "auto" mixed with a real language regardless of order', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 'unknown', + languages: ['en', 'auto'], + }); + expect(result.isError).toBe(true); + expect(result.raw).toContain('auto'); + await close(); + }); + + it('refuses a language entry that is not a two- or three-letter code', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 'unknown', + languages: ['english'], + }); + expect(result.isError).toBe(true); + await close(); + }); + + it('refuses a language declared twice', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 'unknown', + languages: ['en', 'en'], + }); + expect(result.isError).toBe(true); + await close(); + }); +}); + +describe('MCP: transcribe starts a background job', () => { + it('returns a job id and does not block', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 2, + languages: ['en'], + }); + expect(result.isError).toBe(false); + const body = result.json(); + expect(typeof body['jobId']).toBe('string'); + expect(body['jobId']).not.toBe(''); + expect(body['kind']).toBe('transcribe'); + expect(body['poll']).toContain('job_status'); + await close(); + }); + }); + + it('refuses synchronously when a job already holds the lock', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + await withJobLock(ctx.paths.dataDir, async () => { + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 2, + languages: ['en'], + }); + expect(result.isError).toBe(true); + expect(result.json()['error']).toContain('already running'); + expect(spawnDetachedJob).not.toHaveBeenCalled(); + expect(await listJobs(ctx.fs, ctx.paths.jobsDir)).toEqual([]); + await close(); + }); + }); + }); + + it('marks the job failed and reports it when spawning itself throws', async () => { + // The id handed out must always resolve: a job that never actually + // started must not be left saying "running" forever. + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + vi.mocked(spawnDetachedJob).mockImplementation(() => { + throw new Error('spawn boom'); + }); + const { call, close } = await connect(ctx); + const result = await call('transcribe', { + recordingIds: [id], + speakers: 2, + languages: ['en'], + }); + expect(result.isError).toBe(true); + const jobId = result.json()['jobId'] as string; + const state = await getJob(ctx.fs, ctx.paths.jobsDir, jobId); + expect(state?.state).toBe('failed'); + expect(state?.error).toContain('spawn boom'); + await close(); + }); + }); +}); + +describe('MCP: job_status', () => { + it('resolves an id the instant transcribe handed it out', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0007.wav'); + await withRealDataDir(ctx, async () => { + const { call, close } = await connect(ctx); + const started = ( + await call('transcribe', { recordingIds: [id], speakers: 2, languages: ['en'] }) + ).json(); + const status = (await call('job_status', { jobId: started['jobId'] as string })).json(); + expect(status['id']).toBe(started['jobId']); + expect(['running', 'failed', 'done']).toContain(status['state']); + await close(); + }); + }); + + it('reports an unknown job id as unknown, not as a failure', async () => { + const ctx = context(); + const { call, close } = await connect(ctx); + const result = await call('job_status', { jobId: '01K4NOSUCHJOBNOSUCHJOB00' }); + expect(result.isError).toBe(true); + expect(result.json()['error']).toContain('no such job'); + await close(); + }); + + it('never inlines the log, only its path', async () => { + const ctx = context(); + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + // A stand-in for the ~104 lines of backend chatter one whisper run + // writes to the log; the exact line is the one the design doc calls out + // by name. + await ctx.fs.writeTextFile( + job.log, + 'whisper_print_progress_callback: progress = 42%\n'.repeat(20), + ); + const { call, close } = await connect(ctx); + const withId = await call('job_status', { jobId: job.id }); + expect(withId.json()['log']).toBe(job.log); + expect(withId.raw).not.toContain('whisper_print_progress_callback'); + const listed = await call('job_status', {}); + expect(listed.raw).not.toContain('whisper_print_progress_callback'); + await close(); + }); + + it('without an id, lists running jobs plus the five most recent finished ones', async () => { + const ctx = context(); + for (let i = 0; i < 7; i += 1) { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + if (i < 2) continue; // leave the first two running + await writeJobState(ctx.fs, ctx.paths.jobsDir, { + ...job, + state: 'done', + finishedAt: ctx.clock.nowIso(), }); } const { call, close } = await connect(ctx); - const body = (await call('summarize', { recordingIds: ['ID001', 'ID002'] })).json(); - expect(body['reusedStoredReports']).toBe(2); + const body = (await call('job_status', {})).json(); + const jobs = body['jobs'] as { state: string }[]; + expect(jobs.filter((job) => job.state === 'running')).toHaveLength(2); + expect(jobs.filter((job) => job.state !== 'running')).toHaveLength(5); await close(); }); + + /** + * Only a person knows which `speaker_00` is Ann, and an agent holding a + * fresh transcript is the one party able to ask. These cases pin that the + * prompt to do so appears exactly when it is actionable -- never as + * standing advice on a job that cannot use it. + */ + describe('the speaker follow-up', () => { + const doneTranscribe = async ( + ctx: ReturnType, + transcribed: readonly unknown[], + ) => { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + await writeJobState(ctx.fs, ctx.paths.jobsDir, { + ...job, + state: 'done', + finishedAt: ctx.clock.nowIso(), + result: { transcribed }, + }); + return job.id; + }; + + it('asks for names when a finished job left labels unnamed', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0100.wav'); + const jobId = await doneTranscribe(ctx, [ + { recordingId: id, speakers: ['speaker_00', 'speaker_01'] }, + ]); + const { call, close } = await connect(ctx); + const body = (await call('job_status', { jobId })).json(); + expect(body['unnamedSpeakers']).toEqual([ + { recordingId: id, labels: ['speaker_00', 'speaker_01'] }, + ]); + expect(String(body['nextStep'])).toContain('annotate'); + await close(); + }); + + it('says nothing about names once every label has one', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0101.wav'); + await ctx.store.setSpeakerName(id, 'speaker_00', 'Ann'); + await ctx.store.setSpeakerName(id, 'speaker_01', 'Bo'); + const jobId = await doneTranscribe(ctx, [ + { recordingId: id, speakers: ['speaker_00', 'speaker_01'] }, + ]); + const { call, close } = await connect(ctx); + const body = (await call('job_status', { jobId })).json(); + expect(body['unnamedSpeakers']).toBeUndefined(); + expect(body['nextStep']).toBeUndefined(); + await close(); + }); + + it('asks only about the labels still missing a name', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0102.wav'); + await ctx.store.setSpeakerName(id, 'speaker_00', 'Ann'); + const jobId = await doneTranscribe(ctx, [ + { recordingId: id, speakers: ['speaker_00', 'speaker_01'] }, + ]); + const { call, close } = await connect(ctx); + const body = (await call('job_status', { jobId })).json(); + expect(body['unnamedSpeakers']).toEqual([{ recordingId: id, labels: ['speaker_01'] }]); + await close(); + }); + + it('says nothing when the run produced no labels at all', async () => { + // A transcription without --diarize. There is nothing to name, so a + // prompt to name it would be noise on every poll. + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0103.wav'); + const jobId = await doneTranscribe(ctx, [{ recordingId: id, speakers: [] }]); + const { call, close } = await connect(ctx); + const body = (await call('job_status', { jobId })).json(); + expect(body['unnamedSpeakers']).toBeUndefined(); + await close(); + }); + + it('looks nothing up for a recording that has no labels', async () => { + // `job_status` is advertised as cheap enough to poll every few + // minutes, which is why the labels travel in the job's own result + // rather than being read back. Asserting the outcome alone cannot see + // a lookup creeping back in: with no labels the answer is empty either + // way. This asserts the cost. + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0106.wav'); + const jobId = await doneTranscribe(ctx, [{ recordingId: id, speakers: [] }]); + const lookups = vi.spyOn(ctx.store, 'listSpeakerNames'); + const { call, close } = await connect(ctx); + await call('job_status', { jobId }); + expect(lookups).not.toHaveBeenCalled(); + lookups.mockRestore(); + await close(); + }); + + it('says nothing while the job is still running', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0104.wav'); + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + await writeJobState(ctx.fs, ctx.paths.jobsDir, { + ...job, + result: { transcribed: [{ recordingId: id, speakers: ['speaker_00'] }] }, + }); + const { call, close } = await connect(ctx); + const body = (await call('job_status', { jobId: job.id })).json(); + expect(body['state']).toBe('running'); + expect(body['unnamedSpeakers']).toBeUndefined(); + await close(); + }); + + it('says nothing for a summarize job, whatever its result holds', async () => { + const ctx = context(); + const id = await importFixture(ctx, '/in/rec0105.wav'); + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'summarize', recordings: 1, declared: null }, + ); + await writeJobState(ctx.fs, ctx.paths.jobsDir, { + ...job, + state: 'done', + finishedAt: ctx.clock.nowIso(), + result: { transcribed: [{ recordingId: id, speakers: ['speaker_00'] }] }, + }); + const { call, close } = await connect(ctx); + const body = (await call('job_status', { jobId: job.id })).json(); + expect(body['unnamedSpeakers']).toBeUndefined(); + await close(); + }); + + it('survives a result that is not the shape it expects', async () => { + // The result field is `unknown` by design. A job written by an older + // version, or a hand-edited state file, must not turn a poll into an + // error. + const ctx = context(); + const jobId = await doneTranscribe(ctx, []); + const { call, close } = await connect(ctx); + for (const result of [null, 'a string', { transcribed: 'not an array' }, { other: 1 }]) { + const job = await createJob( + { fs: ctx.fs, ids: ctx.ids, clock: ctx.clock, jobsDir: ctx.paths.jobsDir }, + { kind: 'transcribe', recordings: 1, declared: null }, + ); + await writeJobState(ctx.fs, ctx.paths.jobsDir, { + ...job, + state: 'done', + finishedAt: ctx.clock.nowIso(), + result, + }); + const body = (await call('job_status', { jobId: job.id })).json(); + expect(body['state']).toBe('done'); + expect(body['unnamedSpeakers']).toBeUndefined(); + } + expect(jobId).toBeTruthy(); + await close(); + }); + }); }); diff --git a/apps/cli/src/mcp/toolsRead.ts b/apps/cli/src/mcp/toolsRead.ts index 3ff2b57..37f3266 100644 --- a/apps/cli/src/mcp/toolsRead.ts +++ b/apps/cli/src/mcp/toolsRead.ts @@ -13,6 +13,8 @@ import { import type { CliContext } from '../wiring.js'; import { resolveRecording, resolveSummary } from '../resolveId.js'; import { loadTemplates, templatesDir } from '../templateStore.js'; +import { getJob, listJobs } from '../jobs/store.js'; +import type { JobState } from '../jobs/state.js'; import type { McpDeps } from './deps.js'; import { safePathComponent } from './safePath.js'; import { fail, ok } from './reply.js'; @@ -31,6 +33,70 @@ const TAGS = z 'both. Lowercase words.', ); +/** One finished recording, as a transcribe job records it in its result. */ +interface TranscribedEntry { + readonly recordingId: string; + readonly speakers: readonly string[]; +} + +function transcribedEntries(result: unknown): readonly TranscribedEntry[] { + if (typeof result !== 'object' || result === null) return []; + const list = (result as { transcribed?: unknown }).transcribed; + if (!Array.isArray(list)) return []; + return list.filter( + (entry): entry is TranscribedEntry => + typeof entry === 'object' && + entry !== null && + typeof (entry as TranscribedEntry).recordingId === 'string' && + Array.isArray((entry as TranscribedEntry).speakers), + ); +} + +/** + * What to do next, when a finished transcription left speakers unnamed. + * + * Diarization gives every speaker a label like `speaker_00`, and only a + * person can say which of them is Ann. Nothing else in the system knows, and + * an agent that has just been handed a transcript is the one party in a + * position to ask -- so this is the moment to say so, rather than leaving + * every later summary attributing decisions to a number. + * + * Data-driven, not advice: it appears only for a `transcribe` job that has + * actually finished and produced labels that no name covers yet. A recording + * transcribed without `--diarize` has no labels and gets nothing, and so + * does one whose speakers are already named. + * + * Costs one small query per recording and no segment reads -- the labels + * come from the job's own result. `job_status` is advertised as cheap enough + * to poll, and that has to stay true. + */ +async function speakerFollowUp( + context: CliContext, + job: JobState, +): Promise<{ + unnamedSpeakers?: readonly { recordingId: string; labels: readonly string[] }[]; + nextStep?: string; +}> { + if (job.kind !== 'transcribe' || job.state !== 'done') return {}; + const pending: { recordingId: string; labels: readonly string[] }[] = []; + for (const entry of transcribedEntries(job.result)) { + if (entry.speakers.length === 0) continue; + const named = new Set( + (await context.store.listSpeakerNames(entry.recordingId)).map((name) => name.label), + ); + const labels = entry.speakers.filter((label) => !named.has(label)); + if (labels.length > 0) pending.push({ recordingId: entry.recordingId, labels }); + } + if (pending.length === 0) return {}; + return { + unnamedSpeakers: pending, + nextStep: + 'This recording has speakers the diarizer could only number. Ask the user who they are ' + + 'and record it with `annotate` (speakerNames), naming them from the transcript: names ' + + 'survive re-transcription and every later summary uses them.', + }; +} + export function registerReadTools(server: McpServer, context: CliContext, deps: McpDeps): void { server.registerTool( 'list_recordings', @@ -388,4 +454,41 @@ export function registerReadTools(server: McpServer, context: CliContext, deps: note: 'These are editable YAML files. Prefer an existing one; use create_template only when none fits.', }), ); + + server.registerTool( + 'job_status', + { + title: 'How a background job is doing', + description: + 'Reports a transcription or summary job started by `transcribe` or `summarize`.\n\n' + + 'CHEAP, and the right way to wait: poll this rather than blocking. A few minutes ' + + 'between calls is usually enough -- polling does not make the work go faster.\n\n' + + 'Returns state, an APPROXIMATE percentage, the current stage, an ETA once there is ' + + 'enough of the run to estimate from, and the PATH to the job log. The log is a path ' + + 'and not text on purpose: it is a growing trail of stage transitions and warnings ' + + 'over what can be an hour-long run, worth opening only once something failed -- and ' + + "the failure message is already in this reply's `error` field.\n\n" + + 'Without a jobId, lists what is running plus the most recent finished jobs.', + inputSchema: { + jobId: z.string().optional().describe('The id transcribe or summarize returned.'), + }, + annotations: { readOnlyHint: true }, + }, + async ({ jobId }) => { + if (jobId === undefined) { + const jobs = await listJobs(context.fs, context.paths.jobsDir); + const running = jobs.filter((job) => job.state === 'running'); + const finished = jobs.filter((job) => job.state !== 'running').slice(0, 5); + return ok({ jobs: [...running, ...finished] }); + } + const job = await getJob(context.fs, context.paths.jobsDir, jobId); + if (job === null) { + // Unknown, not failed. A pruned or mistyped id is "I lost track of + // this", and an agent that cannot tell it from "this went wrong" + // will report a failure that never happened. + return fail({ error: `no such job: ${jobId}`, hint: 'call job_status with no id to list' }); + } + return ok({ ...job, ...(await speakerFollowUp(context, job)) }); + }, + ); } diff --git a/apps/cli/src/mcp/toolsWrite.ts b/apps/cli/src/mcp/toolsWrite.ts index 8d08e76..579869a 100644 --- a/apps/cli/src/mcp/toolsWrite.ts +++ b/apps/cli/src/mcp/toolsWrite.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { DEFAULT_TEMPLATE, transcribeRecording } from '@ailoud/core'; +import { DEFAULT_TEMPLATE, guessLanguages } from '@ailoud/core'; import type { CliContext } from '../wiring.js'; import { resolveRecording, resolveRecordings } from '../resolveId.js'; import { parseTags } from '../tags.js'; @@ -12,7 +12,11 @@ import { templatesDir, validateTemplateName, } from '../templateStore.js'; -import { runSummary } from '../summarizeRun.js'; +import { JobLog } from '../jobs/log.js'; +import { JobReporter } from '../jobs/reporter.js'; +import { createJob } from '../jobs/store.js'; +import { jobBusyMessage, jobLockHolder } from '../jobs/lock.js'; +import { spawnDetachedJob } from '../jobs/spawn.js'; import type { McpDeps } from './deps.js'; import { fail, ok } from './reply.js'; @@ -20,6 +24,122 @@ const ID = z .string() .describe('A recording id, or any unambiguous prefix of at least two characters.'); +/** + * Mirrors parseLanguages's guarantees (apps/cli/src/commands/transcribe.ts) on + * the array shape this tool receives, given a non-empty `languages`. + * + * This is not cosmetic parity: the result reaches resolveDeclaredLanguages + * (@ailoud/core) exactly the way the CLI's does, and that function does not + * reject a code it cannot use. When no detected span falls inside the + * declared set, its fallback branch stamps declared[0] onto every span, + * silently writing whatever was passed as though it were a real language + * code. "auto" mixed with a real code guarantees that fallback fires -- + * whisper never detects a span as "auto" -- but a mis-typed code, a + * duplicate, or a case mismatch against the lower-case codes providers + * return can trigger the exact same corruption. All are refused here, before + * any recording is resolved, for the same reason the CLI validates before + * starting: the alternative is an hour of transcription whose segments carry + * a language that was never real. + * + * Lower-cases before comparing, same as parseLanguages, so `["RU"]` is + * treated as `["ru"]` rather than silently missing every detected span whose + * language a provider reports in lower case. + * + * Returns the normalised list to declare (empty for a lone "auto"), or a + * refusal reason to hand to `fail`. + */ +function validateLanguages( + languages: readonly string[], +): { readonly declared: readonly string[] } | { readonly refusal: Record } { + const lowered = languages.map((code) => code.toLowerCase()); + if (lowered.length > 1 && lowered.includes('auto')) { + return { + refusal: { + error: `"auto" cannot be mixed with a real language, got [${languages.join(', ')}]`, + why: + '"auto" means detect everything, so naming a language alongside it says two ' + + 'contradictory things', + }, + }; + } + if (lowered.length === 1 && lowered[0] === 'auto') return { declared: [] }; + for (const code of lowered) { + if (!/^[a-z]{2,3}$/.test(code)) { + return { + refusal: { + error: `"${code}" is not a two- or three-letter language code, in [${languages.join(', ')}]`, + }, + }; + } + } + const duplicate = lowered.find((code, index) => lowered.indexOf(code) !== index); + if (duplicate !== undefined) { + return { refusal: { error: `"${duplicate}" is listed twice, in [${languages.join(', ')}]` } }; + } + return { declared: lowered }; +} + +/** + * Builds the detached child's argv for `transcribe`, explicitly from this + * tool's own validated inputs -- never from `process.argv`. Under the MCP + * server that argv is `mcp`, not `transcribe`, so there would be nothing to + * filter anyway; see `spawn.ts`'s `buildDetachedArgs` for the rest of the + * assembly (the entry path and `--job`). + * + * A sibling of `transcribeChildArgs` in `../commands/transcribe.ts` rather + * than a reuse of it: that one's `TranscribeOptions` speaks the CLI's own + * shapes -- one comma-joined `--lang` string, a numeric `--speakers` string + * with no "unknown" -- and adapting this tool's already-validated array and + * `number | 'unknown'` inputs to that shape would be more indirection than + * the function below. + */ +function transcribeChildArgs( + recordingIds: readonly string[], + input: { + readonly declared: readonly string[]; + readonly speakers: number | 'unknown'; + readonly diarize: boolean | undefined; + readonly tags: readonly string[]; + }, +): string[] { + const args: string[] = ['transcribe', ...recordingIds]; + args.push('--lang', input.declared.length === 0 ? 'auto' : input.declared.join(',')); + if (input.diarize === true) args.push('--diarize'); + // Same guard the inline pipeline used to apply: --speakers only informs + // the diarizer, and only when a real count was declared. + if (input.diarize === true && typeof input.speakers === 'number') { + args.push('--speakers', String(input.speakers)); + } + for (const tag of input.tags) args.push('--tag', tag); + return args; +} + +/** + * Builds the detached child's argv for `summarize`. See + * `transcribeChildArgs` above for why this duplicates, rather than reuses, + * `summarizeChildArgs` in `../commands/summarize.ts`: that one also handles + * `--no-save`, which this tool has no equivalent input for, and takes the + * CLI's raw option shapes rather than this tool's already-parsed ones. + */ +function summarizeChildArgs( + recordingIds: readonly string[], + input: { + readonly tags: readonly string[]; + readonly template: string | undefined; + readonly context: string | undefined; + readonly language: string | undefined; + readonly fresh: boolean | undefined; + }, +): string[] { + const args: string[] = ['summarize', ...recordingIds]; + for (const tag of input.tags) args.push('--tag', tag); + if (input.language !== undefined) args.push('--lang', input.language); + if (input.fresh === true) args.push('--fresh'); + if (input.template !== undefined) args.push('--template', input.template); + if (input.context !== undefined) args.push('--context', input.context); + return args; +} + export function registerWriteTools(server: McpServer, context: CliContext, _deps: McpDeps): void { server.registerTool( 'annotate', @@ -141,10 +261,14 @@ export function registerWriteTools(server: McpServer, context: CliContext, _deps description: 'Turns recordings into transcripts with the locally configured speech-to-text engine.\n\n' + 'COSTS MINUTES OF CPU per recording -- roughly a tenth of the audio duration on a fast ' + - "machine, more on a slow one. A long recording may outlast your client's tool timeout; " + - 'if that happens, the work is not lost, and calling again picks up what has no ' + - 'transcript yet.\n\n' + - 'Name the recordings. There is no default selection, deliberately.', + 'machine, more on a slow one. RUNS IN THE BACKGROUND: this call returns at once with a ' + + 'job id, and the work continues after it returns. Poll job_status with that id rather ' + + 'than waiting on this call -- a few minutes between polls is usually enough, since ' + + 'polling does not make the work go faster.\n\n' + + 'Name the recordings. There is no default selection, deliberately.\n\n' + + 'REFUSES until speakers and languages are both given. Whisper cannot be restricted to a ' + + 'set of languages unless told what to expect, so the first call without them comes back ' + + 'with a guess and instructions to ask the user, instead of running.', inputSchema: { recordingIds: z.array(ID).min(1).describe('Recordings to transcribe. Prefixes accepted.'), languages: z @@ -153,7 +277,17 @@ export function registerWriteTools(server: McpServer, context: CliContext, _deps .describe( 'Expected languages, e.g. ["ru","en"]. Giving more than one turns on per-segment ' + 'detection and confines it to that set, which is far more reliable than letting ' + - 'it guess freely.', + 'it guess freely.\n\n' + + "ASK THE USER, and offer your own reading of the recording's name as a starting " + + 'point. Pass ["auto"] only when they do not know.', + ), + speakers: z + .union([z.number().int().positive(), z.literal('unknown')]) + .optional() + .describe( + 'How many people speak on this recording. ASK THE USER -- do not guess. Pass ' + + '"unknown" if they genuinely do not know; that is recorded, and is better than a ' + + 'number nobody believes.', ), diarize: z .boolean() @@ -162,46 +296,91 @@ export function registerWriteTools(server: McpServer, context: CliContext, _deps tags: z.array(z.string()).optional().describe('Tags to add while you are here.'), }, }, - async ({ recordingIds, languages, diarize, tags }) => { - const warnings: string[] = []; + async ({ recordingIds, languages, speakers, diarize, tags }) => { + // Refused rather than defaulted, and refused BEFORE the work starts. + // Declared languages are the difference between a Russian stretch + // transcribed as Russian and the same stretch reported as Polish and + // returned as phonetic nonsense -- see TranscribeOptions.declaredLanguages + // in @ailoud/core. A default would silently pick the worse outcome for + // every caller who never read the rules. + if (speakers === undefined || languages === undefined || languages.length === 0) { + const first = await resolveRecording(context.store, recordingIds[0]!); + const guess = guessLanguages({ + sourcePath: first.sourcePath, + title: first.title, + tags: await context.store.listTags(first.id), + }); + return fail({ + error: 'transcribe needs the speaker count and the expected languages', + why: + 'declared languages stop whisper reporting Polish for a Russian stretch, which then ' + + 'comes back as phonetic nonsense; a known speaker count is more reliable than ' + + 'letting the diarizer infer one', + guess, + ask: + 'Ask the user how many people speak on this recording and in which languages. Offer ' + + 'the guess above, plus your own reading of the name, and let them correct it. Ask ' + + 'per recording when the recordings differ.', + then: 'call transcribe again with speakers and languages', + }); + } + + const validated = validateLanguages(languages); + if ('refusal' in validated) return fail(validated.refusal); + const declared = validated.declared; + + // Resolved before the lock check, same order the CLI's --detach uses: + // an unknown or ambiguous id should cost a refusal, not a job id for a + // job about to fail on it. const recordings = await resolveRecordings(context.store, recordingIds); - const parsed = parseTags(tags ?? []); - const declared = languages ?? []; - const multilingual = declared.length > 1; - const done = []; - for (const recording of recordings) { - const transcript = await transcribeRecording( - { - fs: context.fs, - store: context.store, - audio: context.audio, - stt: context.createStt(), - clock: context.clock, - ids: context.ids, - mediaRoot: context.paths.mediaRoot, - // Warnings reach the caller in the result rather than a terminal: - // there is no terminal here, and a diarizer that failed silently - // would leave an agent believing it has speakers. - onWarning: (message) => warnings.push(message), - ...(multilingual ? { segmenter: context.createSegmenter() } : {}), - ...(diarize === true ? { diarizer: context.createDiarizer() } : {}), - }, - recording, - { - ...(!multilingual && declared.length === 1 ? { language: declared[0] } : {}), - ...(multilingual ? { multilingual: true, declaredLanguages: declared } : {}), - ...(diarize === true ? { diarize: true } : {}), - }, + const tagList = parseTags(tags ?? []); + + // Synchronous refusal: handing back an id for a job about to die + // against the lock is a worse answer than a plain refusal. Advisory, + // like every other read of this lock -- the answer can be stale -- + // but refusing up front on it is still better than not checking. + const holder = await jobLockHolder(context.paths.dataDir); + if (holder !== null) return fail({ error: jobBusyMessage(holder) }); + + const job = await createJob( + { fs: context.fs, ids: context.ids, clock: context.clock, jobsDir: context.paths.jobsDir }, + { + kind: 'transcribe', + recordings: recordings.length, + // The caller's literal declaration, not `declared` (validateLanguages's + // normalised set, which is what reaches the pipeline via + // transcribeChildArgs below). ["auto"] means "the user does not + // know"; an absent `languages` is refused before this point and + // never reaches here at all -- so unlike the CLI's --lang, there is + // no "nothing declared" case to preserve, only this one. + declared: { speakers, languages }, + }, + ); + + try { + await spawnDetachedJob( + { fs: context.fs, jobsDir: context.paths.jobsDir }, + transcribeChildArgs(recordingIds, { declared, speakers, diarize, tags: tagList }), + job, ); - if (parsed.length > 0) await context.store.addTags(recording.id, parsed); - done.push({ - recordingId: recording.id, - transcriptId: transcript.id, - language: transcript.language, - segments: (await context.store.listSegments(transcript.id)).length, - }); + } catch (error) { + // The id below must always resolve: if the child never started, the + // job file must say so rather than "running" forever. + const message = error instanceof Error ? error.message : String(error); + await new JobReporter({ + fs: context.fs, + jobsDir: context.paths.jobsDir, + initial: job, + log: new JobLog(job.log), + }).fail(message); + return fail({ jobId: job.id, error: `failed to start the job: ${message}` }); } - return ok({ transcribed: done, ...(warnings.length === 0 ? {} : { warnings }) }); + + return ok({ + jobId: job.id, + kind: job.kind, + poll: 'call job_status with this id; a few minutes apart is often enough', + }); }, ); @@ -212,8 +391,12 @@ export function registerWriteTools(server: McpServer, context: CliContext, _deps description: 'Writes a summary of one or several recordings with a language model, and saves it as a ' + 'report.\n\n' + - 'COSTS TOKENS on a hosted model, or minutes on a local one. There is no default ' + - 'selection: name the recordings or a tag.\n\n' + + 'COSTS TOKENS on a hosted model, or minutes on a local one. RUNS IN THE BACKGROUND: this ' + + 'call returns at once with a job id rather than the summary text. Poll job_status with ' + + 'it; once its state is "done", the result carries the saved report\'s id -- read the ' + + 'text with get_report only when you actually need it, the same trade get_transcript ' + + 'makes with a transcript. There is no default selection: name the recordings or a ' + + 'tag.\n\n' + 'CALL list_templates FIRST and pass a template. The headings differ because the ' + 'questions differ -- a one-to-one is about agreements and concerns, a design decision ' + 'about what was rejected. The default meeting shape answers those badly.\n\n' + @@ -288,26 +471,45 @@ export function registerWriteTools(server: McpServer, context: CliContext, _deps return fail({ error: `no recordings carry ${tags.join(' and ')}` }); } - // The same pipeline the CLI runs. Written twice before this, and the - // copies drifted: the rule that a single recording is never summarised - // from its own stored summary was fixed in the command and had to be - // remembered separately here. - const result = await runSummary(context, { - recordings, - template, - ...(args.language === undefined ? {} : { language: args.language }), - ...(args.context === undefined ? {} : { context: args.context }), - ...(args.fresh === true ? { fresh: true } : {}), - }); + // Synchronous refusal: handing back an id for a job about to die + // against the lock is a worse answer than a plain refusal. + const holder = await jobLockHolder(context.paths.dataDir); + if (holder !== null) return fail({ error: jobBusyMessage(holder) }); + + const job = await createJob( + { fs: context.fs, ids: context.ids, clock: context.clock, jobsDir: context.paths.jobsDir }, + { kind: 'summarize', recordings: recordings.length, declared: null }, + ); + + try { + await spawnDetachedJob( + { fs: context.fs, jobsDir: context.paths.jobsDir }, + summarizeChildArgs(ids, { + tags, + template: args.template, + context: args.context, + language: args.language, + fresh: args.fresh, + }), + job, + ); + } catch (error) { + // The id below must always resolve: if the child never started, the + // job file must say so rather than "running" forever. + const message = error instanceof Error ? error.message : String(error); + await new JobReporter({ + fs: context.fs, + jobsDir: context.paths.jobsDir, + initial: job, + log: new JobLog(job.log), + }).fail(message); + return fail({ jobId: job.id, error: `failed to start the job: ${message}` }); + } return ok({ - reportId: result.reportId, - template: template.name, - model: `${result.provider} ${result.model}`, - recordingIds: recordings.map((recording) => recording.id), - portions: result.portions, - reusedStoredReports: result.reused, - summary: result.body, + jobId: job.id, + kind: job.kind, + poll: 'call job_status with this id; a few minutes apart is often enough', }); }, ); diff --git a/apps/cli/src/program.test.ts b/apps/cli/src/program.test.ts index 9e68e16..b1aaf8e 100644 --- a/apps/cli/src/program.test.ts +++ b/apps/cli/src/program.test.ts @@ -1,6 +1,7 @@ +import type { Command } from 'commander'; import { afterEach, describe, expect, it } from 'vitest'; import { parseConfig } from './config.js'; -import { EnvironmentError, FailureError, UsageError } from '@ailoud/core'; +import { EnvironmentError, FailureError, resourceBudget, UsageError } from '@ailoud/core'; import { FakeAudioTool, FakeClock, @@ -14,6 +15,8 @@ import { SqliteStore } from '@ailoud/providers'; import { buildProgram, exitCodeFor } from './program.js'; import type { CliContext } from './wiring.js'; import { PlainUi } from './ui/plain.js'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; describe('exitCodeFor', () => { it('maps each domain error to its documented code', () => { @@ -105,10 +108,13 @@ describe('buildProgram', () => { return { paths: { configFile: '/fake/config.yaml', + configHome: '/fake', dataDir: '/fake/data', dbFile: ':memory:', mediaRoot: '/fake/data/media', + jobsDir: '/fake/data/jobs', isProjectLibrary: false, + userDataDir: '/fake/data', }, config: { stt: { @@ -128,6 +134,9 @@ describe('buildProgram', () => { }, }, llm: parseConfig(null).llm, + resources: parseConfig(null).resources, + audio: parseConfig(null).audio, + update: parseConfig(null).update, }, store, fs: new MemFs(), @@ -136,6 +145,7 @@ describe('buildProgram', () => { ids: new FakeIds(), write, ui: new PlainUi(write), + resources: async () => resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: 90 }), createStt: () => new FakeStt({ language: 'en', model: 'fake', segments: [] }), createSegmenter: () => new FakeSegmenter([{ startMs: 0, endMs: 1000 }]), createDiarizer: () => new FakeDiarizer([]), @@ -145,6 +155,11 @@ describe('buildProgram', () => { contextTokens: 8192, complete: async () => 'x', }), + // A fixed list, never the network: this suite drives buildProgram + // end to end, and no test here exercises `self check` itself. + versionSource: { published: async () => [{ version: '1.0.0', deprecated: false }] }, + updateRegistryHost: 'registry.npmjs.org', + updateTimeoutMs: 10_000, }; } @@ -154,15 +169,41 @@ describe('buildProgram', () => { expect(program.description()).toContain('audio-to-text'); }); + it('reports the version in its own manifest', () => { + // It used to report the literal 0.0.0, so the published 1.0.0-dev.1 + // answered `ailoud --version` with 0.0.0 and told MCP clients the same. + // Asserting against the manifest rather than a literal keeps this test + // from needing an edit at every release -- which is what would make it + // rot into agreeing with whatever is there. + const manifest: unknown = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), + ); + const version = (manifest as { version: string }).version; + expect(version).toMatch(/^\d+\.\d+\.\d+/); + expect(buildProgram(makeContext()).version()).toBe(version); + }); + + /** + * Commander writes its usage errors straight to stderr, which is right for + * a CLI and wrong for a test run: two of the tests below made every + * `pnpm test` print "error: unknown option", so a real error in the log had + * to be picked out of expected noise. Only stderr is silenced -- writeOut is + * how buildProgram routes help through context.write, which one of these + * tests asserts on. + */ + function quiet(program: Command): Command { + return program.configureOutput({ writeErr: () => {} }); + } + it('throws instead of exiting the process on an unknown flag', async () => { - const program = buildProgram(makeContext()); + const program = quiet(buildProgram(makeContext())); await expect(program.parseAsync(['node', 'ailoud', '--bogus'])).rejects.toMatchObject({ code: 'commander.unknownOption', }); }); it('maps an unknown flag to exit code 2 end to end', async () => { - const program = buildProgram(makeContext()); + const program = quiet(buildProgram(makeContext())); const error: unknown = await program .parseAsync(['node', 'ailoud', '--bogus']) .catch((caught: unknown) => caught); @@ -186,7 +227,7 @@ describe('buildProgram', () => { // raise 'commander.help' with its own exitCode of 1. That is a usage // error (nothing was told what to do), not a normal failure, so it must // map to 2 here, not fall through as 1. - const program = buildProgram(makeContext()); + const program = quiet(buildProgram(makeContext())); const error: unknown = await program .parseAsync(['node', 'ailoud']) .catch((caught: unknown) => caught); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 64f36d6..a82cea9 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -9,12 +9,16 @@ import { registerRm } from './commands/rm.js'; import { registerAnnotate } from './commands/annotate.js'; import { registerSummarize } from './commands/summarize.js'; import { registerReports } from './commands/reports.js'; +import { registerJobs } from './commands/jobs.js'; import { registerTemplate } from './commands/template.js'; import { registerSearch } from './commands/search.js'; import { registerMcp } from './commands/mcp.js'; import { attachLetters, group, inGroupAndTopLevel } from './commands/groups.js'; import { registerTranscribe } from './commands/transcribe.js'; +import { registerSelfCheck, registerSelfSync, registerSelfUpdate } from './commands/self.js'; +import { registerSelfCompletions } from './commands/selfCompletions.js'; import type { CliContext } from './wiring.js'; +import { VERSION } from './version.js'; /** * Reads the commander error code off an unknown thrown value without @@ -57,7 +61,7 @@ export function buildProgram(context: CliContext): Command { program .name('ailoud') .description('Multilingual audio-to-text with a local recording library') - .version('0.0.0') + .version(VERSION) .exitOverride(); // throw instead of calling process.exit // Commander's own help/version text stays plain in both modes: routed // through context.write, not context.ui, so it is never decorated. @@ -88,6 +92,12 @@ export function buildProgram(context: CliContext): Command { registerReports(report, context); attachLetters(report); + // On the group only, not through inGroupAndTopLevel: the top level is for + // verbs that act on recordings, and these act on jobs. + const job = group(program, 'job', 'jobs', 'Background transcription and summary work'); + registerJobs(job, context); + attachLetters(job); + const template = group( program, 'template', @@ -100,5 +110,15 @@ export function buildProgram(context: CliContext): Command { registerMcp(program, context); registerDoctor(program, context); registerSetup(program, context); + + const self = group(program, 'self', undefined, 'Manage this installation of ailoud'); + inGroupAndTopLevel(program, self, registerSelfCheck, context); + inGroupAndTopLevel(program, self, registerSelfUpdate, context); + inGroupAndTopLevel(program, self, registerSelfSync, context); + // On the group only, not through inGroupAndTopLevel: the top level is for + // verbs that act on recordings, and this acts on the installation. + registerSelfCompletions(self, context); + attachLetters(self); + return program; } diff --git a/apps/cli/src/projectLibrary.test.ts b/apps/cli/src/projectLibrary.test.ts index 1a2c71e..43ca8e3 100644 --- a/apps/cli/src/projectLibrary.test.ts +++ b/apps/cli/src/projectLibrary.test.ts @@ -60,7 +60,7 @@ describe('resolvePaths', () => { it('keeps the config per-user even with a project library', () => { // The config names installed binaries and model files. Making it local - // would mean re-downloading a 488 MB model per repository. + // would mean re-downloading a 574 MB model per repository. const paths = resolvePaths( { ...HOME, XDG_CONFIG_HOME: '/cfg', XDG_DATA_HOME: '/data' }, { cwd: '/work/repo', exists: has('/work/repo/.ailoud') }, diff --git a/apps/cli/src/projects.test.ts b/apps/cli/src/projects.test.ts new file mode 100644 index 0000000..180a73f --- /dev/null +++ b/apps/cli/src/projects.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest'; +import type { Clock } from '@ailoud/core'; +import { MemFs } from '@ailoud/core/testing'; +import { + pruneProjects, + readProjects, + registryPath, + rememberProject, + type ProjectsDeps, +} from './projects.js'; + +const DATA_DIR = '/data/ailoud'; +const DAY_MS = 24 * 60 * 60 * 1000; + +class StubClock implements Clock { + public constructor(private ms: number) {} + public nowIso(): string { + return new Date(this.ms).toISOString(); + } + public advance(ms: number): void { + this.ms += ms; + } +} + +/** A `MemFs` that records every write/rename/removeFile call, in order. */ +class LoggingFs extends MemFs { + readonly calls: string[] = []; + override async writeTextFile(path: string, content: string): Promise { + this.calls.push(`write:${path}`); + return super.writeTextFile(path, content); + } + override async rename(from: string, to: string): Promise { + this.calls.push(`rename:${from}->${to}`); + return super.rename(from, to); + } + override async removeFile(path: string): Promise { + this.calls.push(`removeFile:${path}`); + return super.removeFile(path); + } +} + +function deps(overrides: Partial = {}): ProjectsDeps { + return { + fs: new MemFs({}), + clock: new StubClock(Date.parse('2026-01-01T00:00:00.000Z')), + userDataDir: DATA_DIR, + ...overrides, + }; +} + +describe('rememberProject / readProjects', () => { + it('creates the file on first registration', async () => { + const d = deps(); + + await rememberProject(d, { path: '/proj/a' }); + + expect(await d.fs.exists(registryPath(DATA_DIR))).toBe(true); + const projects = await readProjects(d); + expect(projects).toEqual([ + { + path: '/proj/a', + firstSeen: '2026-01-01T00:00:00.000Z', + lastSeen: '2026-01-01T00:00:00.000Z', + }, + ]); + }); + + it('records firstSeen once and moves lastSeen', async () => { + const clock = new StubClock(Date.parse('2026-01-01T00:00:00.000Z')); + const d = deps({ clock }); + + await rememberProject(d, { path: '/proj/a' }); + clock.advance(DAY_MS + 1); // comfortably past the 24-hour throttle + await rememberProject(d, { path: '/proj/a' }); + + const [entry] = await readProjects(d); + expect(entry).toBeDefined(); + expect(entry?.firstSeen).toBe('2026-01-01T00:00:00.000Z'); + expect(entry?.lastSeen).toBe( + new Date(Date.parse('2026-01-01T00:00:00.000Z') + DAY_MS + 1).toISOString(), + ); + }); + + it('does not write again within 24 hours', async () => { + const clock = new StubClock(Date.parse('2026-01-01T00:00:00.000Z')); + const fs = new LoggingFs({}); + const d = deps({ clock, fs }); + + await rememberProject(d, { path: '/proj/a' }); + const writesAfterFirstCall = fs.calls.length; + + clock.advance(1000); // one second later: well within the 24-hour window + await rememberProject(d, { path: '/proj/a' }); + + expect(fs.calls.length).toBe(writesAfterFirstCall); // no additional write or rename + const [entry] = await readProjects(d); + expect(entry?.lastSeen).toBe('2026-01-01T00:00:00.000Z'); // unchanged + }); + + it('writes again once lastSeen is older than 24 hours', async () => { + const clock = new StubClock(Date.parse('2026-01-01T00:00:00.000Z')); + const fs = new LoggingFs({}); + const d = deps({ clock, fs }); + + await rememberProject(d, { path: '/proj/a' }); + const writesAfterFirstCall = fs.calls.length; + + clock.advance(DAY_MS); // exactly 24 hours later + await rememberProject(d, { path: '/proj/a' }); + + expect(fs.calls.length).toBeGreaterThan(writesAfterFirstCall); + const [entry] = await readProjects(d); + expect(entry?.lastSeen).toBe( + new Date(Date.parse('2026-01-01T00:00:00.000Z') + DAY_MS).toISOString(), + ); + }); + + it('records rulesVersion when rules were written', async () => { + const clock = new StubClock(Date.parse('2026-01-01T00:00:00.000Z')); + const d = deps({ clock }); + + await rememberProject(d, { path: '/proj/a', rulesVersion: '1.2.0' }); + const [afterFirst] = await readProjects(d); + expect(afterFirst?.rulesVersion).toBe('1.2.0'); + + // A rules write is significant enough to record immediately, even though + // the 24-hour throttle would otherwise skip a plain touch. + clock.advance(1000); + await rememberProject(d, { path: '/proj/a', rulesVersion: '1.3.0' }); + const [afterSecond] = await readProjects(d); + expect(afterSecond?.rulesVersion).toBe('1.3.0'); + expect(afterSecond?.lastSeen).toBe(clock.nowIso()); + }); + + it('writes through a temporary file and renames', async () => { + const fs = new LoggingFs({}); + const d = deps({ fs }); + + await rememberProject(d, { path: '/proj/a' }); + + const writeIndex = fs.calls.findIndex((c) => c.startsWith('write:')); + const renameIndex = fs.calls.findIndex((c) => c.startsWith('rename:')); + expect(writeIndex).toBeGreaterThanOrEqual(0); + expect(renameIndex).toBeGreaterThan(writeIndex); // write happens strictly before rename + + const writtenPath = fs.calls[writeIndex]!.slice('write:'.length); + const [, renamedFrom, renamedTo] = fs.calls[renameIndex]!.match(/^rename:(.*)->(.*)$/) ?? []; + expect(renamedFrom).toBe(writtenPath); + expect(renamedTo).toBe(registryPath(DATA_DIR)); + expect(writtenPath).not.toBe(registryPath(DATA_DIR)); // a real, distinct temp file + // Nothing observed the target path directly written to; it only appears + // as the rename's destination. + expect(fs.calls.some((c) => c === `write:${registryPath(DATA_DIR)}`)).toBe(false); + }); + + it('recovers from a corrupt file by moving it aside', async () => { + const fs = new MemFs({ [registryPath(DATA_DIR)]: '{not valid json' }); + const d = deps({ fs }); + + expect(await readProjects(d)).toEqual([]); + expect(await fs.exists(`${DATA_DIR}/projects.json.bad`)).toBe(true); + expect(await fs.exists(registryPath(DATA_DIR))).toBe(false); // moved, not copied + }); + + it('recovers from a file that parses as JSON but not as a registry', async () => { + const fs = new MemFs({ [registryPath(DATA_DIR)]: JSON.stringify({ oops: true }) }); + const d = deps({ fs }); + + expect(await readProjects(d)).toEqual([]); + expect(await fs.exists(`${DATA_DIR}/projects.json.bad`)).toBe(true); + }); +}); + +describe('pruneProjects', () => { + async function seedRegistry(fs: MemFs, entries: unknown[]): Promise { + await fs.ensureDir(DATA_DIR); + await fs.writeTextFile(registryPath(DATA_DIR), JSON.stringify(entries)); + } + + it('prunes an entry whose directory is gone and reports it', async () => { + const fs = new MemFs({}); + await seedRegistry(fs, [ + { + path: '/gone', + firstSeen: '2026-01-01T00:00:00.000Z', + lastSeen: '2026-01-01T00:00:00.000Z', + }, + ]); + // '/gone' is deliberately absent from fs.dirs. + const d = deps({ fs }); + + const dropped = await pruneProjects(d); + + expect(dropped).toHaveLength(1); + expect(dropped[0]?.path).toBe('/gone'); + expect(await readProjects(d)).toEqual([]); + }); + + it('keeps an entry whose .ailoud is gone but whose directory remains', async () => { + const fs = new MemFs({}); + fs.dirs.add('/proj/b'); // the project directory still exists... + // ...but its library, '/proj/b/.ailoud', is never added: it is gone. + await seedRegistry(fs, [ + { + path: '/proj/b', + libraryDir: '/proj/b/.ailoud', + firstSeen: '2026-01-01T00:00:00.000Z', + lastSeen: '2026-01-01T00:00:00.000Z', + }, + ]); + const d = deps({ fs }); + + const dropped = await pruneProjects(d); + + expect(dropped).toEqual([]); + const projects = await readProjects(d); + expect(projects).toHaveLength(1); + expect(projects[0]?.path).toBe('/proj/b'); + }); + + it('never deletes anything on disk while pruning', async () => { + const fs = new LoggingFs({}); + fs.dirs.add('/proj/kept'); + // '/proj/gone' is absent, so it will be dropped from the registry. + await seedRegistry(fs, [ + { + path: '/proj/kept', + firstSeen: '2026-01-01T00:00:00.000Z', + lastSeen: '2026-01-01T00:00:00.000Z', + }, + { + path: '/proj/gone', + firstSeen: '2026-01-01T00:00:00.000Z', + lastSeen: '2026-01-01T00:00:00.000Z', + }, + ]); + const d = deps({ fs }); + + const dropped = await pruneProjects(d); + + expect(dropped).toHaveLength(1); + expect(fs.calls.some((c) => c.startsWith('removeFile:'))).toBe(false); + }); +}); + +describe('the three concurrency and throttle defects found in review', () => { + it('keeps an entry another process committed while we were writing', async () => { + // Two processes registering DIFFERENT projects both read the registry, + // both write, and the second rename wins -- so the first project used to + // vanish outright, not merely with a stale lastSeen. The merge re-reads + // immediately before the rename and keeps paths it had not seen. + class RacingFs extends MemFs { + private reads = 0; + override async readTextFile(path: string): Promise { + this.reads += 1; + // The second read is the one inside writeRegistry, standing in for a + // rival process that committed /proj/b in the meantime. + if (this.reads === 2 && path === registryPath(DATA_DIR)) { + return `${JSON.stringify([ + { + path: '/proj/b', + firstSeen: '2026-01-01T00:00:00.000Z', + lastSeen: '2026-01-01T00:00:00.000Z', + }, + ])}\n`; + } + return super.readTextFile(path); + } + } + const fs = new RacingFs({}); + await fs.writeTextFile(registryPath(DATA_DIR), '[]\n'); + const d = deps({ fs }); + + await rememberProject(d, { path: '/proj/a' }); + + const paths = (await readProjects(d)).map((entry) => entry.path).sort(); + expect(paths).toEqual(['/proj/a', '/proj/b']); + }); + + it('does not fail the command when quarantining a corrupt registry fails', async () => { + // Two processes meeting one corrupt registry both try to move it aside. + // The loser's source is already gone, so rename answers ENOENT. Letting + // that escape would turn a bad bookkeeping file into a broken command, + // which is the one thing this path exists to prevent. + class UnmovableFs extends MemFs { + override async rename(): Promise { + throw new Error('ENOENT: no such file or directory'); + } + } + const fs = new UnmovableFs({}); + await fs.writeTextFile(registryPath(DATA_DIR), '{ not json'); + const d = deps({ fs }); + + await expect(readProjects(d)).resolves.toEqual([]); + }); + + it('does not bypass the throttle when the rules version has not changed', async () => { + // Presence is not enough: a caller passing the same version on every + // touch would write on every command, which is the throttle gone. + const fs = new LoggingFs({}); + const clock = new StubClock(Date.parse('2026-01-01T00:00:00.000Z')); + const d = deps({ fs, clock }); + + await rememberProject(d, { path: '/proj/a', rulesVersion: '1.0.0' }); + const afterFirst = fs.calls.length; + clock.advance(1000); + await rememberProject(d, { path: '/proj/a', rulesVersion: '1.0.0' }); + expect(fs.calls.length).toBe(afterFirst); + + // A CHANGED version is a real event and must be recorded at once. + await rememberProject(d, { path: '/proj/a', rulesVersion: '1.0.1' }); + expect(fs.calls.length).toBeGreaterThan(afterFirst); + }); +}); diff --git a/apps/cli/src/projects.ts b/apps/cli/src/projects.ts new file mode 100644 index 0000000..1be2420 --- /dev/null +++ b/apps/cli/src/projects.ts @@ -0,0 +1,214 @@ +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import type { Clock, Fs } from '@ailoud/core'; + +/** One project ailoud has been used in, as remembered across runs. */ +export interface ProjectEntry { + readonly path: string; + readonly libraryDir?: string; + readonly firstSeen: string; + readonly lastSeen: string; + readonly rulesVersion?: string; +} + +export interface ProjectsDeps { + readonly fs: Fs; + readonly clock: Clock; + /** + * The per-user data directory (`AiloudPaths.userDataDir`), never + * `AiloudPaths.dataDir`. `dataDir` follows the current project's `.ailoud/` + * when there is one, and a registry stored inside one project would only + * ever list that project. + */ + readonly userDataDir: string; +} + +/** How long a registration is left untouched before a re-run bumps `lastSeen` again. */ +const REFRESH_AFTER_MS = 24 * 60 * 60 * 1000; + +const ProjectEntrySchema = z.object({ + path: z.string(), + libraryDir: z.string().optional(), + firstSeen: z.string(), + lastSeen: z.string(), + rulesVersion: z.string().optional(), +}); + +const RegistrySchema = z.array(ProjectEntrySchema); + +export function registryPath(userDataDir: string): string { + return `${userDataDir}/projects.json`; +} + +/** Where a registry that failed to parse is moved, so it never blocks a run. */ +function quarantinePath(userDataDir: string): string { + return `${userDataDir}/projects.json.bad`; +} + +/** + * Writes the registry atomically: a temporary file beside the real one, then + * a rename over the top. Writing in place would let a concurrent reader see a + * half-written file, and the temporary name is randomised per call so two + * writers never share -- and corrupt -- one temporary file. + * + * `merge` exists because a plain read-modify-write loses more than a + * timestamp. Two processes registering DIFFERENT projects both read the + * registry, both write, and the second rename wins -- so the first project + * disappears entirely, not merely with a stale `lastSeen`. Re-reading + * immediately before the rename and keeping any path we had not seen narrows + * that window to the rename itself, and a project lost inside it re-registers + * the next time ailoud runs there. + * + * Pruning passes `merge: false`, deliberately: it is REMOVING entries, and + * merging would read the very entries it just dropped back in. + */ +async function writeRegistry( + deps: ProjectsDeps, + entries: readonly ProjectEntry[], + options: { readonly merge: boolean }, +): Promise { + const path = registryPath(deps.userDataDir); + let next = entries; + if (options.merge) { + const mine = new Set(entries.map((entry) => entry.path)); + const latest = await readProjects(deps); + const theirs = latest.filter((entry) => !mine.has(entry.path)); + next = [...entries, ...theirs]; + } + const tempPath = `${path}.${randomUUID()}.tmp`; + await deps.fs.ensureDir(deps.userDataDir); + await deps.fs.writeTextFile(tempPath, `${JSON.stringify(next, null, 2)}\n`); + await deps.fs.rename(tempPath, path); +} + +/** + * Moves an unparseable registry aside, and never throws doing it. + * + * The move itself can fail: two processes reading the same corrupt registry + * both try it, and the loser's source is already gone, so `rename` answers + * ENOENT. Letting that escape would crash the command -- turning a bad + * bookkeeping file into a broken `ailoud ls`, which is exactly what this + * whole quarantine path exists to prevent. + */ +async function quarantine(deps: ProjectsDeps, path: string): Promise { + try { + await deps.fs.rename(path, quarantinePath(deps.userDataDir)); + } catch { + // Someone else moved it, or the directory is not writable. Either way the + // caller gets an empty list and the command carries on. + } +} + +/** + * Every project ailoud has been used in. + * + * A registry is a convenience, not a source of truth: a file that fails to + * parse must never fail the command that asked for it. It is instead moved + * aside to `projects.json.bad` so a human can look at it, and reading + * proceeds as though there were no registry yet. + */ +export async function readProjects(deps: ProjectsDeps): Promise { + const path = registryPath(deps.userDataDir); + if (!(await deps.fs.exists(path))) return []; + + const raw = await deps.fs.readTextFile(path); + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + await quarantine(deps, path); + return []; + } + + const result = RegistrySchema.safeParse(document); + if (!result.success) { + await quarantine(deps, path); + return []; + } + return result.data; +} + +/** + * Records that a project was just used, creating the registry on first use. + * + * Skips the write when the project was already seen within the last 24 + * hours: the hot path is a command that touches a project library reading + * this file on every run, and writing on every run would put a disk write on + * something as routine as `ailoud ls`. `rulesVersion` is the exception -- + * when it is given, rules were just written to the project, which is worth + * recording immediately rather than waiting out the throttle. + */ +export async function rememberProject( + deps: ProjectsDeps, + project: { path: string; libraryDir?: string; rulesVersion?: string }, +): Promise { + const projects = await readProjects(deps); + const now = deps.clock.nowIso(); + const index = projects.findIndex((entry) => entry.path === project.path); + + if (index === -1) { + const entry: ProjectEntry = { + path: project.path, + firstSeen: now, + lastSeen: now, + ...(project.libraryDir === undefined ? {} : { libraryDir: project.libraryDir }), + ...(project.rulesVersion === undefined ? {} : { rulesVersion: project.rulesVersion }), + }; + await writeRegistry(deps, [...projects, entry], { merge: true }); + return; + } + + const existing = projects[index]!; + const staleMs = Date.parse(now) - Date.parse(existing.lastSeen); + // Presence is not enough: a caller that passes the same version on every + // touch would bypass the throttle on every command, which is the throttle + // gone. Only a CHANGE means rules were just rewritten. + const rulesJustWritten = + project.rulesVersion !== undefined && project.rulesVersion !== existing.rulesVersion; + if (staleMs < REFRESH_AFTER_MS && !rulesJustWritten) return; + + const updated: ProjectEntry = { + ...existing, + lastSeen: now, + ...(project.libraryDir === undefined ? {} : { libraryDir: project.libraryDir }), + ...(project.rulesVersion === undefined ? {} : { rulesVersion: project.rulesVersion }), + }; + const next = [...projects]; + next[index] = updated; + await writeRegistry(deps, next, { merge: true }); +} + +/** + * Drops entries whose project directory is gone, and returns what was + * dropped so the caller can report it. + * + * Checked against `entry.path` itself, not a `.ailoud/` beneath it: a project + * whose library is gone can still hold the rules block sync exists to + * refresh, so losing the library is not reason enough to forget the project. + * Never removes anything on disk -- only the registry file is rewritten, and + * only when there is something to drop. + */ +export async function pruneProjects(deps: ProjectsDeps): Promise { + const projects = await readProjects(deps); + const kept: ProjectEntry[] = []; + const dropped: ProjectEntry[] = []; + for (const entry of projects) { + // A throw here is NOT evidence the project is gone. `isDirectory` rethrows + // EACCES -- revoked permissions, an unreachable network mount -- and + // dropping the entry on that would forget a project that still exists and + // still holds a rules block. So an unanswerable question keeps the entry, + // and only a definite "not a directory" drops it. + let present: boolean; + try { + present = await deps.fs.isDirectory(entry.path); + } catch { + present = true; + } + if (present) kept.push(entry); + else dropped.push(entry); + } + // merge: false, deliberately. Pruning REMOVES entries, and merging would + // re-read the very entries it just dropped straight back in. + if (dropped.length > 0) await writeRegistry(deps, kept, { merge: false }); + return dropped; +} diff --git a/apps/cli/src/setupLock.test.ts b/apps/cli/src/setupLock.test.ts index 78e5e25..60c6f00 100644 --- a/apps/cli/src/setupLock.test.ts +++ b/apps/cli/src/setupLock.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { lockPath, withProvisioningLock } from './setupLock.js'; @@ -72,6 +75,41 @@ describe('withProvisioningLock', () => { await expect(withProvisioningLock(dir, async () => 'took over')).resolves.toBe('took over'); }); + it('leaves no scratch file behind after taking over a stale lock', async () => { + // The takeover writes its own lock beside the path and renames over it, + // because `rm` then create loses the race it looks like it wins. A rename + // that failed, or a path built wrong, would leave the scratch file in the + // data directory. + const dir = await dataDir(); + await writeFile( + lockPath(dir), + JSON.stringify({ pid: 4_194_304, startedAt: '2020-01-01T00:00:00.000Z' }), + 'utf8', + ); + let duringBody: string[] = []; + await withProvisioningLock(dir, async () => { + duringBody = await readdir(dir); + }); + expect(duringBody).toEqual(['provisioning.lock']); + expect(await readdir(dir)).toEqual([]); + }); + + it('records the taking-over process as the holder, not the stale one', async () => { + const dir = await dataDir(); + await writeFile( + lockPath(dir), + JSON.stringify({ pid: 4_194_304, startedAt: '2020-01-01T00:00:00.000Z' }), + 'utf8', + ); + let held: unknown; + await withProvisioningLock(dir, async () => { + held = JSON.parse(await readFile(lockPath(dir), 'utf8')); + }); + // The read-back after the rename is what makes a losing takeover + // detectable; this is the winning side of it. + expect(held).toMatchObject({ pid: process.pid }); + }); + it('treats an empty lock file as stale', async () => { const dir = await dataDir(); // A run that died between creating the lock and writing to it. @@ -97,3 +135,82 @@ describe('withProvisioningLock', () => { await rm(dir, { recursive: true, force: true }); }); }); + +/** + * Two runs at once, against the COMPILED lock, with a stale lock in place. + * + * Every other test here is single-threaded, which is why none of them caught + * that the first two takeover implementations let both runs proceed -- a + * concurrency test found 34 overlaps in 60 attempts. This is that test, small + * enough to keep in the suite. + */ +describe('withProvisioningLock under contention', () => { + const ITERATIONS = 12; + const HOLD_MS = 120; + + it('lets exactly one of two simultaneous runs into the body', async () => { + const dist = fileURLToPath(new URL('../dist/setupLock.js', import.meta.url)); + expect(existsSync(dist)).toBe(true); // the gate builds before it tests + + const scratch = await mkdtemp(join(tmpdir(), 'ailoud-lock-race-')); + const worker = join(scratch, 'worker.mjs'); + await writeFile( + worker, + `import { appendFileSync } from "node:fs"; + import { withProvisioningLock } from ${JSON.stringify(dist)}; + const [dir, log] = process.argv.slice(2); + try { + await withProvisioningLock(dir, async () => { + appendFileSync(log, \`ENTER \${process.pid}\n\`); + await new Promise((r) => setTimeout(r, ${HOLD_MS})); + appendFileSync(log, \`LEAVE \${process.pid}\n\`); + }); + } catch { + appendFileSync(log, \`REFUSED \${process.pid}\n\`); + }`, + 'utf8', + ); + + let refusals = 0; + for (let round = 0; round < ITERATIONS; round += 1) { + const dir = join(scratch, `round-${round}`); + await mkdir(dir, { recursive: true }); + // A stale lock: pid 2^22 is above every default pid_max, so nothing + // holds it. This is the state a Ctrl-C or a crash leaves behind, and the + // only state in which a takeover happens at all. + await writeFile( + lockPath(dir), + JSON.stringify({ pid: 4_194_304, startedAt: '2020-01-01T00:00:00.000Z' }), + 'utf8', + ); + const log = join(dir, 'log.txt'); + await writeFile(log, '', 'utf8'); + + await Promise.all( + [0, 1].map( + () => + new Promise((resolve) => { + const child = spawn(process.execPath, [worker, dir, log], { stdio: 'ignore' }); + child.on('exit', () => resolve()); + }), + ), + ); + + const lines = (await readFile(log, 'utf8')).split('\n').filter(Boolean); + refusals += lines.filter((l) => l.startsWith('REFUSED')).length; + // Nesting is the failure: ENTER, ENTER means both are inside the body. + let inside = 0; + for (const line of lines) { + if (line.startsWith('ENTER')) inside += 1; + if (line.startsWith('LEAVE')) inside -= 1; + expect(inside, `round ${round}: ${lines.join(' | ')}`).toBeLessThanOrEqual(1); + } + expect(lines.filter((l) => l.startsWith('ENTER'))).toHaveLength(1); + } + + // Without this the test could pass by never contending at all. + expect(refusals).toBeGreaterThan(0); + + await rm(scratch, { recursive: true, force: true }); + }, 60_000); +}); diff --git a/apps/cli/src/setupLock.ts b/apps/cli/src/setupLock.ts index 014d7e8..621efbf 100644 --- a/apps/cli/src/setupLock.ts +++ b/apps/cli/src/setupLock.ts @@ -1,57 +1,10 @@ -import { mkdir, open, readFile, rm } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { FailureError } from '@ailoud/core'; - -/** What a held lock records, so a human can see who holds it. */ -interface LockHolder { - readonly pid: number; - readonly startedAt: string; -} +import { join } from 'node:path'; +import { withExclusiveLock } from './exclusiveLock.js'; export function lockPath(dataDir: string): string { return join(dataDir, 'provisioning.lock'); } -/** - * Whether the process that wrote a lock is still running. - * - * Signal 0 performs the permission and existence checks without delivering - * anything. ESRCH means no such process, so the lock is stale. EPERM means - * the process EXISTS but belongs to another user -- a live lock, and the - * most dangerous case to get wrong, because treating it as stale would let - * two runs proceed at once, which is the whole thing this prevents. - */ -function isRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -/** - * Reads a lock file, or reports it unreadable. - * - * A file that exists but is empty, truncated, or not valid JSON is a run - * that died between creating the lock and writing to it. That is stale by - * definition, and must not surface to the user as a parse error about a - * file they have never heard of. - */ -async function readHolder(path: string): Promise { - try { - const raw = await readFile(path, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null) return null; - const { pid, startedAt } = parsed as Partial; - if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null; - if (typeof startedAt !== 'string' || startedAt === '') return null; - return { pid, startedAt }; - } catch { - return null; - } -} - /** * Takes an exclusive lock for the duration of `body`. * @@ -60,54 +13,19 @@ async function readHolder(path: string): Promise { * streaming. Before this, that produced a confusing failure rather than a * clean refusal. * - * Acquired by creating the file with the `wx` flag, which fails if it - * already exists. That is one atomic syscall; a check followed by a create - * would leave a window for the other process to win in between, which is - * exactly the race being closed. - * - * A live lock is refused immediately rather than waited on. Provisioning is - * interactive and can sit on a consent prompt for minutes, so a queued - * second run would look like a hang. The refusal names the holder's pid and - * start time so the user can decide whether to wait or go and look at it. - * - * A stale lock is taken over. After a crash or a Ctrl-C that skipped - * cleanup, the file outlives its process, and a lock nobody can ever - * release would be worse than no lock at all. + * Provisioning is interactive and can sit on a consent prompt for minutes, + * so a queued second run would look like a hang. */ -export async function withProvisioningLock(dataDir: string, body: () => Promise): Promise { - const path = lockPath(dataDir); - await mkdir(dirname(path), { recursive: true }); - - let handle; - try { - handle = await open(path, 'wx'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - - const holder = await readHolder(path); - if (holder !== null && isRunning(holder.pid)) { - throw new FailureError( +export function withProvisioningLock(dataDir: string, body: () => Promise): Promise { + return withExclusiveLock( + lockPath(dataDir), + { + busy: (holder) => `another ailoud provisioning run is already in progress (pid ${holder.pid}, started ` + - `${holder.startedAt}). Wait for it to finish, or stop it, then try again.`, - ); - } - // Stale: the holder is gone, or never finished writing who it was. - await rm(path, { force: true }); - handle = await open(path, 'wx'); - } - - try { - const holder: LockHolder = { pid: process.pid, startedAt: new Date().toISOString() }; - await handle.writeFile(JSON.stringify(holder), 'utf8'); - } finally { - await handle.close(); - } - - try { - return await body(); - } finally { - // force: a lock already gone is the outcome we wanted anyway, and - // failing to clean up must never mask what the body was doing. - await rm(path, { force: true }); - } + `${holder.startedAt}). Wait for it to finish, or stop it, then try again.`, + stealing: 'another ailoud provisioning run is taking over a stale lock right now. Try again.', + raced: 'another ailoud provisioning run took the lock at the same moment. Try again.', + }, + body, + ); } diff --git a/apps/cli/src/summarizeRun.ts b/apps/cli/src/summarizeRun.ts index a09ca02..830a289 100644 --- a/apps/cli/src/summarizeRun.ts +++ b/apps/cli/src/summarizeRun.ts @@ -1,5 +1,12 @@ import { FailureError, buildSummaryRequest } from '@ailoud/core'; -import type { Recording, Summarizer, Summary, SummarySource, SummaryTemplate } from '@ailoud/core'; +import type { + Recording, + ResourceBudget, + Summarizer, + Summary, + SummarySource, + SummaryTemplate, +} from '@ailoud/core'; import type { CliContext } from './wiring.js'; /** @@ -26,6 +33,8 @@ export interface SummaryRun { readonly fresh?: boolean; /** Store the result. Default true. */ readonly save?: boolean; + /** How much of this machine the summarizer may use. Absent means the engine's own default. */ + readonly budget?: ResourceBudget; } export interface SummaryRunResult { @@ -101,7 +110,7 @@ export async function runSummary( run: SummaryRun, hooks: SummaryHooks = {}, ): Promise { - const summarizer = context.createSummarizer(); + const summarizer = context.createSummarizer(run.budget); // Stored summaries stand in for transcripts only when there are several // recordings, which is where they pay: ten meetings from ten stored diff --git a/apps/cli/src/ui/plain.test.ts b/apps/cli/src/ui/plain.test.ts index 74c144b..f2d10f1 100644 --- a/apps/cli/src/ui/plain.test.ts +++ b/apps/cli/src/ui/plain.test.ts @@ -77,6 +77,14 @@ describe('PlainUi', () => { expect(lines).toEqual([]); }); + it('stays silent while transcribing, whatever progress is reported', async () => { + const { ui: sink, lines } = ui(); + await sink.transcribing(RECORDING, async (report) => { + report('transcribing', 0.46); + }); + expect(lines).toEqual([]); + }); + it('reports a transcribed recording exactly like the old context.out line', () => { const { ui: sink, lines } = ui(); sink.transcribed(RECORDING, TRANSCRIPT, 1, []); diff --git a/apps/cli/src/ui/plain.ts b/apps/cli/src/ui/plain.ts index 71896d3..23c55a0 100644 --- a/apps/cli/src/ui/plain.ts +++ b/apps/cli/src/ui/plain.ts @@ -45,10 +45,17 @@ export class PlainUi implements Ui { ); } - public async transcribing(_recording: Recording, task: () => Promise): Promise { + public async transcribing( + _recording: Recording, + task: (report: (stage: string, fraction: number) => void) => Promise, + ): Promise { // No progress output while the work runs: whisper.cpp already prints - // nothing on its own, and the plain path must match that silence. - return task(); + // nothing on its own, and the plain path must match that silence. The + // callback is accepted and ignored -- PlainUi is what runs whenever + // stdout is not a terminal, including under an agent's shell and under a + // redirect, so a redrawn percentage here would write control bytes into + // someone's piped output. + return task(() => {}); } public async summarising( @@ -127,6 +134,14 @@ export class PlainUi implements Ui { if (note !== null) this.write(`note: ${note}`); } + public success(message: string): void { + // Same "ok " prefix `checks()` uses for a passing check, not a new + // vocabulary: one greppable shape for "this succeeded" across the whole + // plain renderer, since PlainUi is what runs whenever stdout is not a + // terminal. + this.write(`ok ${message}`); + } + public warn(message: string): void { this.write(`warning: ${message}`); } diff --git a/apps/cli/src/ui/pretty.test.ts b/apps/cli/src/ui/pretty.test.ts index d5771bb..fb51a7f 100644 --- a/apps/cli/src/ui/pretty.test.ts +++ b/apps/cli/src/ui/pretty.test.ts @@ -232,6 +232,16 @@ describe('PrettyUi.transcribing', () => { expect(spinnerHandle.error).toHaveBeenCalledTimes(1); expect(spinnerHandle.stop).not.toHaveBeenCalled(); }); + + it('shows the transcription percentage on the spinner', async () => { + spinnerHandle.message.mockClear(); + const ui = new PrettyUi(); + await ui.transcribing(A_RECORDING, async (report) => { + report('transcribing', 0.46); + }); + const messages = spinnerHandle.message.mock.calls.map((call) => call[0] as string); + expect(messages.some((line) => line.includes('46%'))).toBe(true); + }); }); describe('PrettyUi.recordings', () => { diff --git a/apps/cli/src/ui/pretty.ts b/apps/cli/src/ui/pretty.ts index d8ff537..41a81f0 100644 --- a/apps/cli/src/ui/pretty.ts +++ b/apps/cli/src/ui/pretty.ts @@ -141,12 +141,17 @@ export class PrettyUi implements Ui { return `${head} ${ellipsis}${trimmed}`; } - public async transcribing(recording: Recording, task: () => Promise): Promise { + public async transcribing( + recording: Recording, + task: (report: (stage: string, fraction: number) => void) => Promise, + ): Promise { const label = recording.title ?? recording.sourcePath; const s = spinner(); s.start(this.fitSpinnerLine(`Transcribing ${recording.id}`, label)); try { - const result = await task(); + const result = await task((stage, fraction) => { + s.message(this.fitSpinnerLine(`${stage} ${Math.floor(fraction * 100)}%`, label)); + }); s.stop(this.fitSpinnerLine(`Transcribed ${recording.id}`, label)); return result; } catch (error) { @@ -346,6 +351,10 @@ export class PrettyUi implements Ui { if (note !== null) log.info(this.wrap(note)); } + public success(message: string): void { + log.success(this.wrap(message)); + } + public warn(message: string): void { log.warn(this.wrap(message)); } diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index bd028c5..666b380 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -27,6 +27,33 @@ export interface Check { * Present when `ailoud setup` / `doctor --fix` can repair this check * without a human. Absent means the repair needs judgment -- see * `Remedy`'s doc comment. + * + * Attached on a PASSING check too, whenever `remedy` genuinely repairs the + * exact thing this check inspects -- `ailoud setup --force` reads it off a + * passing check to reinstall something that already works (a corrupted + * file still passes an existence check; `--force` is the only way to + * replace it). This supersedes the original rule ("attach a Remedy to + * every failing Check ... never to a passing one"): that rule predates + * `--force`, which needs exactly the checks a plain failing-only filter + * throws away. + * + * The one thing that does NOT carry onto a passing check: a remedy that is + * a SUBSTITUTE for the thing being checked -- an alternative path offered + * only because the real thing is missing -- rather than a repair of it. + * `checkLanguageModel`'s claude-cli branch is the example: it checks the + * Claude Code CLI, but its remedy installs llama.cpp (a fallback local + * model, offered when Claude Code isn't there), which does not repair + * Claude Code at all. Carried onto a passing check, `--force` would + * brew-install llama.cpp for someone whose Claude Code is fine and who + * never asked for a local summariser -- so that check strips its own + * `remedy` back off when it passes. When adding a check, ask whether its + * remedy fixes what THIS check inspects, or offers an alternative for when + * it can't be fixed; only the former belongs on the passing branch. + * + * `checkMediaRoot` is the one deliberate exception on the other side: its + * remedy (`create-directory`) IS a repair, but it is left off the passing + * branch anyway, because recreating an already-writable directory is a + * pure no-op that would only add a line of noise to every `--force` plan. */ readonly remedy?: Remedy; /** @@ -77,12 +104,21 @@ export interface Ui { imported(recording: Recording, alreadyPresent: boolean): void; /** - * Runs `task`, the actual transcription work, decorating it with - * progress feedback (a spinner naming `recording`, in pretty mode). - * Returns whatever `task` resolves to, and rethrows whatever it throws, - * so callers can treat this as a transparent wrapper around the call. + * Runs `task`, the actual transcription work, decorating it with progress + * feedback (a spinner naming `recording`, in pretty mode). + * + * `report(stage, fraction)` updates that feedback. Shaped like + * `summarising`'s reporter but taking a fraction rather than done/total: + * transcription progress is a proportion computed from weighted stages, + * not a count of anything a reader would recognise. + * + * Returns whatever `task` resolves to, and rethrows whatever it throws, so + * callers can treat this as a transparent wrapper around the call. */ - transcribing(recording: Recording, task: () => Promise): Promise; + transcribing( + recording: Recording, + task: (report: (stage: string, fraction: number) => void) => Promise, + ): Promise; /** * Runs the summary work behind a spinner, with a way to say how far along it @@ -159,6 +195,17 @@ export interface Ui { /** `doctor` finished running its checks: render the full report. */ checks(checks: readonly Check[]): void; + /** + * A status outcome that succeeded -- e.g. one file `mcp install` wrote, or + * one project `self sync` actually refreshed. Distinct from `content()`, + * which reports payload rather than an outcome, and from the frame's own + * success status, which speaks for the whole command rather than one + * outcome inside it. Used only where the thing being reported genuinely + * carries an enumerated status -- decorating a line that is not one of + * several possible outcomes would make this marker mean nothing. + */ + success(message: string): void; + /** * A non-fatal problem worth the user's attention, e.g. `--diarize` failing * to produce speaker labels. Distinct from the frame's own failure diff --git a/apps/cli/src/updateLog.test.ts b/apps/cli/src/updateLog.test.ts new file mode 100644 index 0000000..db46c84 --- /dev/null +++ b/apps/cli/src/updateLog.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { MemFs } from '@ailoud/core/testing'; +import { appendUpdateLog, updateLogPath } from './updateLog.js'; + +const DATA_DIR = '/data/ailoud'; + +describe('updateLogPath', () => { + it('sits directly under the per-user data directory', () => { + expect(updateLogPath(DATA_DIR)).toBe('/data/ailoud/update.log'); + }); +}); + +describe('appendUpdateLog', () => { + it('creates the log on first use', async () => { + const fs = new MemFs({}); + expect(await fs.exists(updateLogPath(DATA_DIR))).toBe(false); + + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'first line'); + + expect(await fs.exists(updateLogPath(DATA_DIR))).toBe(true); + expect(await fs.readTextFile(updateLogPath(DATA_DIR))).toBe('first line\n'); + }); + + it('appends subsequent lines rather than overwriting', async () => { + const fs = new MemFs({}); + + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'one'); + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'two'); + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'three'); + + expect(await fs.readTextFile(updateLogPath(DATA_DIR))).toBe('one\ntwo\nthree\n'); + }); + + it('does not touch a log under the 1 MB threshold', async () => { + const fs = new MemFs({}); + + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'small'); + const content = await fs.readTextFile(updateLogPath(DATA_DIR)); + + expect(content).toBe('small\n'); + }); + + it('caps the log at the last 500 lines once it passes 1 MB', async () => { + const fs = new MemFs({}); + // 20,000 lines at roughly 60 bytes each is comfortably over 1 MB. + const seedLines = Array.from({ length: 20_000 }, (_, i) => `old line ${i} ${'x'.repeat(50)}`); + await fs.ensureDir(DATA_DIR); + await fs.writeTextFile(updateLogPath(DATA_DIR), `${seedLines.join('\n')}\n`); + + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'the newest line'); + + const content = await fs.readTextFile(updateLogPath(DATA_DIR)); + const lines = content.split('\n').filter((line) => line.length > 0); + expect(lines.length).toBeLessThanOrEqual(500); + // The truncation drops the OLDEST lines, never the write that triggered it. + expect(lines[lines.length - 1]).toBe('the newest line'); + expect(lines[0]).not.toBe(seedLines[0]); + }); + + it('never contains more bytes than the 1 MB cap plus one line, after truncating', async () => { + const fs = new MemFs({}); + const seedLines = Array.from({ length: 20_000 }, (_, i) => `old line ${i} ${'x'.repeat(50)}`); + await fs.ensureDir(DATA_DIR); + await fs.writeTextFile(updateLogPath(DATA_DIR), `${seedLines.join('\n')}\n`); + + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'the newest line'); + + const content = await fs.readTextFile(updateLogPath(DATA_DIR)); + expect(Buffer.byteLength(content, 'utf8')).toBeLessThan(1024 * 1024); + }); + + it('never loses an entry to a second writer racing on the same file', async () => { + // Simulates two processes (a scheduled `self sync` and a manual `self + // update`, say) both calling appendUpdateLog close together. `RacingFs` + // captures what THIS call read as its starting point, then -- while + // that call is still building its own write -- lets a second, complete + // call to appendUpdateLog run and commit first. A plain read-modify-write + // (no re-check before committing) would then have the first call + // overwrite the second's line outright when it finally writes, which is + // exactly the defect found in review: "two processes both read, and the + // second write drops the first's line with no error." + class RacingFs extends MemFs { + private reads = 0; + private armed = true; + override async readTextFile(path: string): Promise { + this.reads += 1; + // Snapshot BEFORE letting the racing call run, so this call sees + // the bytes as they were at ITS read, not after the race. + const snapshot = await super.readTextFile(path); + if (this.reads === 1 && this.armed) { + this.armed = false; + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'second process'); + } + return snapshot; + } + } + const fs = new RacingFs({}); + await fs.ensureDir(DATA_DIR); + await fs.writeTextFile(updateLogPath(DATA_DIR), 'start\n'); + + await appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'first process'); + + const content = await fs.readTextFile(updateLogPath(DATA_DIR)); + const lines = content.split('\n').filter((l) => l.length > 0); + expect(lines).toContain('second process'); + expect(lines).toContain('first process'); + }); +}); + +describe('the append gives up rather than spinning forever', () => { + it('drops the line after a bounded number of lost races', async () => { + // The loop retries only on genuine contention, so hanging needs a + // continuous stream of rival writers -- implausible for a once-per-update + // diagnostic, and cheap to rule out entirely. A rival that ALWAYS commits + // between the re-read and the rename is what an unbounded loop could not + // survive. + // + // What this test proves, exactly: that the bound EXISTS, because it + // returns. It does NOT turn a regression into a clean failure -- + // measured: with the bound removed this test HANGS, and vitest's + // per-test timeout does not preempt it, because the loop awaits only + // already-resolved promises and never yields to a timer. So if this ever + // hangs instead of failing, the bound is what went missing. Stated + // plainly because the first version of this comment claimed the timeout + // would catch it, and that was untrue. + class AlwaysLoses extends MemFs { + public reads = 0; + public override async readTextFile(path: string): Promise { + this.reads += 1; + // Every second read -- the verification read -- reports different + // bytes, so the attempt always looks lost. + if (path.endsWith('update.log') && this.reads % 2 === 0) { + return `rival line ${this.reads}\n`; + } + return super.readTextFile(path).catch(() => ''); + } + public override async exists(): Promise { + return true; + } + } + const fs = new AlwaysLoses({}); + + await expect(appendUpdateLog({ fs, userDataDir: DATA_DIR }, 'mine')).resolves.toBeUndefined(); + }, 5000); +}); diff --git a/apps/cli/src/updateLog.ts b/apps/cli/src/updateLog.ts new file mode 100644 index 0000000..10d0edb --- /dev/null +++ b/apps/cli/src/updateLog.ts @@ -0,0 +1,119 @@ +import { randomUUID } from 'node:crypto'; +import type { Fs } from '@ailoud/core'; + +/** The log is truncated once it grows past this many bytes. */ +const MAX_BYTES = 1024 * 1024; // 1 MB + +/** How many of the most recent lines survive a truncation. */ +/** How many times an append will retry a lost race before dropping the line. */ +const MAX_ATTEMPTS = 5; + +const MAX_LINES = 500; + +export interface UpdateLogDeps { + readonly fs: Fs; + readonly userDataDir: string; +} + +/** + * Where the plain-text log of `self sync` and `self update` actions lives. + * Per-user, like the project registry: it must never live inside a project's + * own `.ailoud/`, or it would only ever record that one project's history. + */ +export function updateLogPath(userDataDir: string): string { + return `${userDataDir}/update.log`; +} + +/** + * Keeps only the last `maxLines` lines of `text`. + * + * `text` always ends in exactly one trailing newline (every write this + * module makes ends that way), so splitting on "\n" leaves one empty + * element at the end that is not a line of its own -- it is dropped before + * slicing, and the `join` below adds the trailing newline back. + */ +function keepLastLines(text: string, maxLines: number): string { + const lines = text.split('\n'); + const last = lines[lines.length - 1]; + const withoutTrailingEmpty = last === '' ? lines.slice(0, -1) : lines; + const kept = withoutTrailingEmpty.slice(-maxLines); + return kept.length === 0 ? '' : `${kept.join('\n')}\n`; +} + +/** + * Appends one line to the update log, creating it (and its directory) on + * first use. + * + * Two processes racing here used to lose an entry outright: both read the + * same bytes, both built "everything so far, plus my line", and whichever + * wrote last replaced the file with only ITS line appended -- the other's + * was gone, with no error anywhere. The same read-modify-write race + * `projects.ts` was redesigned to avoid for the project registry. + * + * An O_APPEND write is the textbook fix for exactly this shape of race: the + * kernel serializes small appends, so neither writer's bytes are ever + * discarded no matter how the two calls interleave. The `Fs` port has no + * such primitive, though -- only whole-file `writeTextFile`/`rename` -- so + * that is not available here. Instead this detects the conflict itself: + * after building the candidate content and + * writing it to a temp file, it re-reads the real log and checks whether it + * still matches what this call read at the start. If another writer + * committed in between, the temp file is discarded and the whole + * read-build-write is retried against the fresh content, until a rename + * lands against the same bytes it was built from. That narrows the race to + * the gap between that last re-read and the rename -- not zero, the same + * residual window `writeRegistry` (`projects.ts`) documents and accepts for + * the same reason -- without a lock: this is an occasional diagnostic + * append, not a hot path, so a retry costs nothing worth avoiding. + * + * Truncated to the last 500 lines once the file passes 1 MB, so a machine + * ailoud has run on for years does not grow this file forever. The check + * runs after every append rather than on a schedule, because the file is + * never large enough for that read-and-measure to be a cost worth avoiding. + * + * Callers must never pass a credential or any transcript text: this line is + * meant to be safe to paste into a bug report. + */ +export async function appendUpdateLog(deps: UpdateLogDeps, line: string): Promise { + const path = updateLogPath(deps.userDataDir); + await deps.fs.ensureDir(deps.userDataDir); + + // Bounded, deliberately. The loop only spins on genuine contention -- a + // persistent failure throws instead -- so an unbounded `for (;;)` needs a + // continuous stream of rival writers to hang, which a once-per-update + // diagnostic append will not see. Two lines is a cheap price for removing + // the possibility altogether, and dropping the line is the right answer on + // giving up: this file exists to explain what happened, and losing one + // entry is incomparably better than hanging the command that was writing + // it. The same rule the project registry follows. + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + const before = (await deps.fs.exists(path)) ? await deps.fs.readTextFile(path) : ''; + const appended = `${before}${line}\n`; + const next = + Buffer.byteLength(appended, 'utf8') > MAX_BYTES + ? keepLastLines(appended, MAX_LINES) + : appended; + + const tempPath = `${path}.${randomUUID()}.tmp`; + try { + await deps.fs.writeTextFile(tempPath, next); + } catch (error) { + // The target has not been touched yet, so there is nothing to undo. + // Clear the partial temporary file rather than leaving litter behind. + await deps.fs.removeFile(tempPath); + throw error; + } + + const current = (await deps.fs.exists(path)) ? await deps.fs.readTextFile(path) : ''; + if (current !== before) { + // Someone else committed while this attempt was being built: discard + // it and retry against what is actually on disk now, rather than + // renaming over -- and silently erasing -- their line. + await deps.fs.removeFile(tempPath); + continue; + } + await deps.fs.rename(tempPath, path); + return; + } + // Every attempt lost the race. Dropping the line is correct -- see above. +} diff --git a/apps/cli/src/updateNotice.test.ts b/apps/cli/src/updateNotice.test.ts new file mode 100644 index 0000000..165700a --- /dev/null +++ b/apps/cli/src/updateNotice.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PublishedVersion } from '@ailoud/core'; +import { MemFs, FakeClock } from '@ailoud/core/testing'; +import { startUpdateCheck, updateCachePath } from './updateNotice.js'; +import type { NoticeDeps } from './updateNotice.js'; + +const DATA_DIR = '/data/ailoud'; + +/** Lets every pending microtask run, so an "already settled" fake fetch or + * cache read has actually resolved before a test calls `finish()`. Real + * usage never needs this: the command being run does real work between + * `startUpdateCheck` and `finish()`, which is what this stands in for. */ +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +/** A `published` that resolves with a fixed list, never touching a network. */ +function published(versions: readonly PublishedVersion[]): NoticeDeps['published'] { + return () => Promise.resolve(versions); +} + +/** A `published` that never settles -- the shape a hung connection has from + * this module's point of view, whether or not it is ever aborted. */ +function hangingPublished(): NoticeDeps['published'] { + return () => new Promise(() => undefined); +} + +/** A `published` that behaves like the real `registryPublished`: it never + * settles on its own within a test's lifetime, but REJECTS the instant its + * signal is aborted -- exactly what `https.request`'s own `signal` option + * does. `hangingPublished` above does not model this at all (it ignores the + * signal entirely), which is exactly why the review found the abort-poisons + * -the-cache defect survived every existing test: none of them exercised a + * fetch that actually reacts to being aborted. */ +function abortablePublished(): NoticeDeps['published'] { + return (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); +} + +function baseDeps(overrides: Partial = {}): NoticeDeps { + return { + fs: new MemFs(), + clock: new FakeClock(), + userDataDir: DATA_DIR, + currentVersion: '1.0.0', + argv: ['ls'], + env: {}, + stderrIsTTY: true, + checkEnabled: true, + published: published([{ version: '1.1.0', deprecated: false }]), + ...overrides, + }; +} + +describe('startUpdateCheck', () => { + it('prints nothing and waits for nothing when the fetch has not settled', async () => { + // The whole point. Awaiting the network before exit would add up to two + // seconds to `ailoud ls`. + const notice = startUpdateCheck(baseDeps({ published: hangingPublished() })); + expect(await notice.finish()).toBeNull(); + }); + + it('prints from the cache when this run could not refresh it', async () => { + const fs = new MemFs({ + [updateCachePath(DATA_DIR)]: JSON.stringify({ + checkedAt: '2026-01-01T00:00:00.000Z', + target: '1.2.3', + }), + }); + // A fetch that would hang forever if it were ever started: a fresh cache + // must answer without going anywhere near the network. No `await + // flush()` here: real usage never performs one between + // `startUpdateCheck()` and `finish()`, and a test that needs one to pass + // is proving nothing about production -- see `finish()`'s own bounded + // wait, which is what actually gives this cache read a chance to win. + const deps = baseDeps({ fs, published: hangingPublished() }); + + const notice = startUpdateCheck(deps); + + expect(await notice.finish()).toBe('1.2.3'); + }); + + it('bounds a slow disk read instead of waiting for it', async () => { + // Stands in for a slow or momentarily unresponsive filesystem (a + // network home directory, a sleeping external drive, a contended or + // nearly full disk): the read resolves, but only long after any + // budget this module may spend waiting for it. Real usage has no + // signal to cancel this with -- `Fs` offers none -- so the only + // available fix is bounding how long anything here waits for it, + // never assuming the disk itself can be made to stop. Unref'd so this + // fixture alone cannot hold the test process open: the property under + // test is whether the SOURCE gives up on it, not whether this fake + // does. + const fs = new MemFs(); + fs.exists = (): Promise => + new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 3000); + timer.unref(); + }); + let called = false; + const deps = baseDeps({ + fs, + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await notice.finish(); + // Well past this module's own ~50ms budget, but nowhere near the + // disk's 3 second delay: if the disk read were still being awaited, + // the registry would not have been reached yet, because `readCache` + // runs before it on every path. + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(called).toBe(true); + }, 2500); + + it('unrefs the bounded wait timer, so it alone cannot keep the process alive', async () => { + const createdTimers: NodeJS.Timeout[] = []; + const realSetTimeout = globalThis.setTimeout; + const spy = vi.spyOn(globalThis, 'setTimeout'); + spy.mockImplementation(((...args: Parameters) => { + const timer = realSetTimeout(...args); + createdTimers.push(timer); + return timer; + }) as typeof setTimeout); + + try { + // A fetch that never settles, so `finish()` can only ever be answered + // by the bounded wait's own timer -- exactly the timer under test. + const notice = startUpdateCheck(baseDeps({ published: hangingPublished() })); + expect(await notice.finish()).toBeNull(); + } finally { + spy.mockRestore(); + } + + expect(createdTimers.length).toBeGreaterThan(0); + for (const timer of createdTimers) { + // hasRef() is Node's own answer to "does this timer count against the + // event loop staying open": false is what lets a command whose own + // work is already done exit immediately, unheld by this wait. + expect(timer.hasRef()).toBe(false); + } + }); + + it('caches a failure too, so a broken network costs one attempt a day', async () => { + const fs = new MemFs(); + const deps = baseDeps({ + fs, + published: () => Promise.reject(new Error('registry unreachable')), + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + const raw = fs.files.get(updateCachePath(DATA_DIR)); + expect(raw).toBeDefined(); + expect(JSON.parse(raw!)).toMatchObject({ target: null }); + }); + + it('never caches a null caused by its own abort, so the next run retries', async () => { + // The defect found in review: BOUND_MS covers the whole check, so a + // fast command's fetch is always still in flight when finish() gives + // up and aborts it. `abortablePublished` rejects on that abort exactly + // as the real HTTPS client does -- if the catch block cached that + // rejection as a genuine "no update", the notice could never fire. + const fs = new MemFs(); + const deps = baseDeps({ fs, published: abortablePublished() }); + + const notice = startUpdateCheck(deps); + expect(await notice.finish()).toBeNull(); + // Give the abort's rejection a chance to reach the catch block; a real + // command's own work would take far longer than this in practice. + await flush(); + + expect(fs.files.has(updateCachePath(DATA_DIR))).toBe(false); + }, 2000); + + it('fills the cache with a genuine answer once the fetch actually settles', async () => { + // The consequence documented on startUpdateCheck: a command slow enough + // to outlast the round trip is the one that fills the cache. Simulated + // here by letting the fetch resolve (via flush()) before finish() is + // even called, the way a real multi-second command would without a + // test needing to wait multiple seconds. + const fs = new MemFs(); + const deps = baseDeps({ + fs, + published: published([{ version: '1.2.3', deprecated: false }]), + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBe('1.2.3'); + const raw = fs.files.get(updateCachePath(DATA_DIR)); + expect(raw).toBeDefined(); + expect(JSON.parse(raw!)).toMatchObject({ target: '1.2.3' }); + }); + + it('is silent when stderr is not a TTY', async () => { + let called = false; + const deps = baseDeps({ + stderrIsTTY: false, + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + }); + + it('is silent with --json', async () => { + let called = false; + const deps = baseDeps({ + argv: ['ls', '--json'], + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + }); + + it('is silent with --format json', async () => { + // `show --format json` is machine-readable output too, and with + // stdout and stderr merged this notice would land inside it just like + // `--json` would. + let called = false; + const deps = baseDeps({ + argv: ['show', 'abc123', '--format', 'json'], + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + }); + + it('is silent in the MCP server', async () => { + let called = false; + const deps = baseDeps({ + argv: ['mcp'], + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + }); + + it('is silent when AILOUD_NO_UPDATE_CHECK is set', async () => { + let called = false; + const deps = baseDeps({ + env: { AILOUD_NO_UPDATE_CHECK: '1' }, + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + }); + + it('is silent when config update.check is false', async () => { + let called = false; + const deps = baseDeps({ + checkEnabled: false, + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + }); + + it('is silent for self check and self update themselves', async () => { + const argvs = [ + ['self', 'check'], + ['self', 'c'], + ['self', 'update'], + ['self', 'u'], + ['check'], + ['update'], + ]; + for (const argv of argvs) { + let called = false; + const deps = baseDeps({ + argv, + published: () => { + called = true; + return Promise.resolve([{ version: '1.1.0', deprecated: false }]); + }, + }); + + const notice = startUpdateCheck(deps); + await flush(); + + expect(await notice.finish()).toBeNull(); + expect(called).toBe(false); + } + }); +}); + +describe('the MCP suppression must not catch a run that only prints help', () => { + it('stays silent for the bare mcp server', async () => { + const notice = startUpdateCheck(baseDeps({ argv: ['mcp'] })); + expect(await notice.finish()).toBeNull(); + }); + + it.each([['--help'], ['-h'], ['--version']])( + 'still speaks for `mcp %s`, which starts no server', + async (flag) => { + // Stripping every `-` argument before deciding made this look like the + // bare `mcp` command. A suppression rule that fires on the wrong input + // is how one eventually fails to fire on the right one. + const notice = startUpdateCheck(baseDeps({ argv: ['mcp', flag] })); + expect(await notice.finish()).toBe('1.1.0'); + }, + ); +}); diff --git a/apps/cli/src/updateNotice.ts b/apps/cli/src/updateNotice.ts new file mode 100644 index 0000000..fcd4492 --- /dev/null +++ b/apps/cli/src/updateNotice.ts @@ -0,0 +1,305 @@ +import { z } from 'zod'; +import type { Clock, Fs, PublishedVersion } from '@ailoud/core'; +import { chooseUpdateTarget } from '@ailoud/core'; + +/** How long a cached answer is trusted before this run asks the registry again. */ +const TTL_MS = 24 * 60 * 60 * 1000; + +/** Resolves at once with whichever the fetch or nothing settles first. */ +const SENTINEL = Symbol('update-check-not-settled'); + +/** + * How long any single `Fs` call on this path may take. A local cache read is + * sub-millisecond, so this is generous for a healthy disk and decisive on a + * sick one (a network home directory, a sleeping external drive, a + * contended or nearly full disk). Any timeout here means "say nothing" -- + * never an error, and never a claim of being up to date. + * + * `finish()` below reuses this same value to bound how much longer it will + * wait, past its own call, for the in-flight check to settle -- but that is + * NOT the same as bounding the registry round trip itself to 50ms. By the + * time `finish()` runs, the disk read has already had the command's own + * work (plus this much again) to complete, so a cache hit almost always + * wins outright; a cache MISS instead falls through to the live fetch, + * which this bound then gives up WAITING for. Giving up still aborts the + * fetch (so the process can exit right away instead of lingering on the + * network), but see `startUpdateCheck`'s own doc comment for why an abort + * must never be confused with a genuine answer worth caching. + */ +const BOUND_MS = 50; + +/** + * Races `promise` against an unref'd timer of `BOUND_MS`, rejecting if the + * timer wins. Every caller on this path already treats any rejection here + * identically to a real I/O error, so a slow disk costs at most `BOUND_MS`, + * never the disk's own unbounded worst case. The timer is unref'd for the + * same reason `sentinelAfter`'s is below: it must never itself be the thing + * that keeps a command's process alive. + */ +function withFsTimeout(promise: Promise): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('fs call exceeded its bound')), BOUND_MS); + timer.unref(); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** + * Resolves to SENTINEL after `ms`, and never before. The timer backing this + * is unref'd: an unref'd timer does not count against Node keeping the + * event loop open, so a command whose own work is already done still exits + * right away, exactly as if this timer did not exist. Without `unref()` + * here, this bounded wait would itself reintroduce the very delay this + * module exists to avoid -- do not "simplify" it away. + */ +function sentinelAfter(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(SENTINEL), ms); + timer.unref(); + }); +} + +/** Everything `startUpdateCheck` needs, so a test never touches a real clock, + * filesystem, network, environment or `process.argv`. */ +export interface NoticeDeps { + readonly fs: Fs; + readonly clock: Clock; + /** `AiloudPaths.userDataDir`: always per-user, never a project's `.ailoud/`. */ + readonly userDataDir: string; + readonly currentVersion: string; + /** `process.argv.slice(2)`: the words after the node binary and script path. */ + readonly argv: readonly string[]; + readonly env: Record; + /** Whether stderr is attached to a real terminal, not a pipe or a log file. */ + readonly stderrIsTTY: boolean; + /** The `update.check` config key. */ + readonly checkEnabled: boolean; + /** Fetches the published versions, honoring `signal` for cancellation. */ + readonly published: (signal: AbortSignal) => Promise; +} + +/** What `finish()` answers: the version to mention, or null for "say nothing". */ +export interface UpdateCheck { + /** Resolves at once: the settled result, or null if it has not arrived. */ + finish(): Promise; +} + +/** True when `--json` appears anywhere on the command line, or when + * `--format json` does (`ailoud show --format json`): both put + * machine-readable output on stdout, and with stdout and stderr merged (a + * redirect, or any tool that captures both) this notice would land inside + * it either way. Machine output stays machine output. */ +function hasJsonFlag(argv: readonly string[]): boolean { + if (argv.includes('--json')) return true; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--format') return argv[i + 1] === 'json'; + if (arg !== undefined && arg.startsWith('--format=')) { + return arg.slice('--format='.length) === 'json'; + } + } + return false; +} + +/** True only for the bare `mcp` command, which speaks the MCP protocol over + * stdout for as long as the client stays connected -- never `mcp install`, + * which is an ordinary one-shot CLI command like any other. */ +function isServingMcp(argv: readonly string[]): boolean { + // `--help` and `--version` never start a server, so they must not be + // stripped before deciding. Stripping every `-` argument first made + // `ailoud mcp --help` look like the bare `mcp` command and suppressed the + // notice on a run that prints ordinary help. Harmless in effect, but a + // suppression rule that fires on the wrong input is how one eventually + // fails to fire on the right one. + const words = argv.filter((arg) => !arg.startsWith('-')); + if (words[0] !== 'mcp' || words[1] !== undefined) return false; + return !argv.some((arg) => arg === '--help' || arg === '-h' || arg === '--version'); +} + +/** True for `self check` and `self update` themselves (every spelling: the + * group form, its one-letter alias, and the hidden top-level alias) -- they + * just said it in full, so a passive mention right after would be noise. */ +function isSelfCheckOrUpdate(argv: readonly string[]): boolean { + const words = argv.filter((arg) => !arg.startsWith('-')); + const [first, second] = words; + if (first === 'check' || first === 'update') return true; + if (first !== 'self') return false; + return second === 'check' || second === 'c' || second === 'update' || second === 'u'; +} + +function suppressed(deps: NoticeDeps): boolean { + if (!deps.stderrIsTTY) return true; + if (hasJsonFlag(deps.argv)) return true; + if (isServingMcp(deps.argv)) return true; + if (deps.env['AILOUD_NO_UPDATE_CHECK'] !== undefined) return true; + if (!deps.checkEnabled) return true; + if (isSelfCheckOrUpdate(deps.argv)) return true; + return false; +} + +const CacheSchema = z.object({ + checkedAt: z.string(), + target: z.string().nullable(), +}); + +type CacheEntry = z.infer; + +/** Where the once-a-day cache lives: per-user, like the project registry and + * the update log, so being inside a project library never scopes it down. */ +export function updateCachePath(userDataDir: string): string { + return `${userDataDir}/update-check.json`; +} + +/** The cached answer, or null when there is none, it does not parse, or it + * has aged past the 24 hour TTL. A cache that fails to read never throws: + * the worst outcome is one extra check, not a broken command. */ +async function readCache(deps: NoticeDeps): Promise { + const path = updateCachePath(deps.userDataDir); + let raw: string; + try { + if (!(await withFsTimeout(deps.fs.exists(path)))) return null; + raw = await withFsTimeout(deps.fs.readTextFile(path)); + } catch { + return null; + } + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + return null; + } + const result = CacheSchema.safeParse(document); + if (!result.success) return null; + const ageMs = Date.parse(deps.clock.nowIso()) - Date.parse(result.data.checkedAt); + if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > TTL_MS) return null; + return result.data; +} + +/** Writes the cache, on a success or a failure alike -- see this module's own + * doc comment on `startUpdateCheck` for why a failure is cached too. Never + * throws: a directory that cannot be written costs one extra check a day, + * not a crash of whatever command triggered it. */ +async function writeCache(deps: NoticeDeps, target: string | null): Promise { + const entry: CacheEntry = { checkedAt: deps.clock.nowIso(), target }; + try { + await withFsTimeout(deps.fs.ensureDir(deps.userDataDir)); + await withFsTimeout( + deps.fs.writeTextFile(updateCachePath(deps.userDataDir), `${JSON.stringify(entry)}\n`), + ); + } catch { + // Best effort, as above. + } +} + +/** + * Starts a once-a-day, best-effort check for a newer `ailoud`, without ever + * making a command slower. + * + * The fetch (or the cache read behind it) is started here and deliberately + * NOT awaited: awaiting the network before a command's own work is done + * would add up to `updateTimeoutMs` to every single run of, say, `ailoud + * ls`. Suppression is checked FIRST, synchronously, so a suppressed run + * never starts a fetch at all -- there is nothing to abort and nothing to + * cache. + * + * `finish()` is how a caller collects the answer without ever waiting long + * for it: it races the in-flight check against `sentinelAfter(BOUND_MS)`, an + * unref'd timer of about 50ms -- not, as an earlier version of this module + * did, an ALREADY-RESOLVED sentinel promise. That older race was a single + * microtask, which sounds free but was not: a real fetch or disk read can + * never win a race against a promise that has already resolved, because the + * sentinel's continuation runs before the event loop even reaches the phase + * where that answer could arrive. Racing against a real, if small, timer + * instead gives a fast, healthy check a genuine chance to settle first. If + * it does, its answer wins. If it does not, the timer wins instead: + * `finish()` aborts the in-flight check (so the process is free to exit + * right away, rather than lingering until the network gives up on its own) + * and returns null. The timer being unref'd is what keeps this bounded wait + * from ever being able to make a command slower than `BOUND_MS` on its own: + * an unref'd timer never counts against the event loop staying open, so a + * command whose own work is already done still exits immediately. The next + * run's cache read is what prints from the attempt this one could not + * finish. + * + * A GENUINE failure is cached as null too, exactly like "no update found": a + * version check must never read as news, and caching the failure is what + * limits a broken registry (a bad HTTP status, an unreadable body, a + * connection that fails on its own) to one wasted attempt a day rather than + * one per command. + * + * An ABORT -- `finish()` giving up on this check and calling + * `controller.abort()` -- is deliberately NOT a genuine failure and is never + * cached. Caching it would be indistinguishable from a completed check that + * found nothing, and for a fast command (`ailoud ls`, `ailoud search`, most + * of them) the fetch is *always* aborted before a real registry round trip + * can finish: bounding `finish()`'s wait is what keeps this module from ever + * making a command slower, but it also means a fast command can basically + * never be the one to complete its own fetch. Caching that as "no update" + * would have made the notice permanently unreachable -- exactly the defect + * this doc comment now exists to prevent. Instead, `finish()` still aborts + * the fetch when it gives up (so the process is free to exit right away), + * but the abort itself writes NOTHING to the cache: the next run (of + * anything) starts fresh and tries again. The consequence to accept is that + * the cache only ever gets a genuine answer from a command slow enough to + * outlast the round trip -- `transcribe`, `summarize`, `setup`, or any run + * against an already-warm connection -- and every fast command in between + * prints from whatever one of those left behind. That is the honest shape + * of "never delay, but still eventually say something": only the WAIT for + * the fetch is bounded, never the fetch's own right to keep running and to + * report a genuine answer whenever it actually settles. + */ +export function startUpdateCheck(deps: NoticeDeps): UpdateCheck { + if (suppressed(deps)) return { finish: async () => null }; + + const controller = new AbortController(); + + const inflight = (async (): Promise => { + const cached = await readCache(deps); + if (cached !== null) return cached.target; + try { + const versions = await deps.published(controller.signal); + const target = chooseUpdateTarget(deps.currentVersion, versions); + await writeCache(deps, target); + return target; + } catch { + if (controller.signal.aborted) { + // WE did this, by giving up in finish() below -- not the registry. + // An abort says nothing about whether a newer version exists, so + // nothing is written: see this function's own doc comment for why + // caching it here is exactly the defect that made the notice + // unreachable in the first place. + return null; + } + // A genuine failure: the registry answered, badly, or the connection + // failed on its own. Caching it is what limits a broken network to + // one wasted attempt a day rather than one per command. + await writeCache(deps, null); + return null; + } + })(); + + return { + async finish(): Promise { + // Whichever wins: the check, or the bounded wait. The wait's timer is + // unref'd (see `sentinelAfter`), so it alone can never keep the + // process alive -- do not replace it with a plain timer "for + // simplicity"; that would reintroduce exactly the delay this module + // exists to avoid. + const raced = await Promise.race([inflight, sentinelAfter(BOUND_MS)]); + if (raced === SENTINEL) { + controller.abort(); + return null; + } + return raced; + }, + }; +} diff --git a/apps/cli/src/version.ts b/apps/cli/src/version.ts new file mode 100644 index 0000000..e199fbc --- /dev/null +++ b/apps/cli/src/version.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; + +/** + * The CLI's own version, read from its manifest. + * + * It used to be the literal `0.0.0` passed to commander, so the published + * 1.0.0-dev.1 answered `ailoud --version` with 0.0.0 and told every MCP client + * the same. The manifest is the one place a release already updates, and it + * ships inside the tarball, so it is the only copy that cannot go stale. + * + * Resolved relative to this module rather than to the working directory: + * `dist/version.js` sits one level below the package root both in the + * repository and in an installed package. + */ +export const VERSION: string = readVersion(); + +function readVersion(): string { + const manifest = new URL('../package.json', import.meta.url); + const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8')); + const version = + typeof parsed === 'object' && parsed !== null + ? (parsed as { version?: unknown }).version + : undefined; + if (typeof version !== 'string' || version === '') { + throw new Error(`no version in ${manifest.pathname}`); + } + return version; +} diff --git a/apps/cli/src/wiring.test.ts b/apps/cli/src/wiring.test.ts index c898ffb..cdf17b0 100644 --- a/apps/cli/src/wiring.test.ts +++ b/apps/cli/src/wiring.test.ts @@ -1,9 +1,16 @@ -import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { EnvironmentError } from '@ailoud/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EnvironmentError, resourceBudget } from '@ailoud/core'; +import { NodeFs } from '@ailoud/providers'; import { createContext } from './wiring.js'; +import type { CliContext } from './wiring.js'; +import { buildProgram } from './program.js'; +import { registryPath } from './projects.js'; +import { VERSION } from './version.js'; +import { context as fakeCliContext } from './commands/testContext.js'; describe('createContext', () => { const dirs: string[] = []; @@ -181,3 +188,231 @@ describe('createContext', () => { } }); }); + +describe('project registration (task 10)', () => { + const dirs: string[] = []; + + afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + async function tempHome(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-wiring-reg-home-')); + dirs.push(dir); + return dir; + } + + /** A directory with its own `.ailoud/`, so `createContext` treats it as a project library. */ + async function tempProject(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-wiring-reg-project-')); + dirs.push(dir); + await mkdir(join(dir, '.ailoud'), { recursive: true }); + return dir; + } + + /** A plain directory, with no `.ailoud/` anywhere above it, for the per-user path. */ + async function tempPlainDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'ailoud-wiring-reg-plain-')); + dirs.push(dir); + return dir; + } + + async function readRegistry(home: string): Promise { + const raw = await readFile(registryPath(join(home, '.local', 'share', 'ailoud')), 'utf8'); + return JSON.parse(raw) as unknown[]; + } + + it('registers a project when a command resolves its library', async () => { + const home = await tempHome(); + const project = await tempProject(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(project); + const context = await createContext({ HOME: home }, () => {}); + try { + expect(context.paths.isProjectLibrary).toBe(true); + const entries = (await readRegistry(home)) as Array<{ + path: string; + libraryDir?: string; + }>; + expect(entries).toEqual([ + expect.objectContaining({ path: project, libraryDir: join(project, '.ailoud') }), + ]); + } finally { + context.store.close(); + cwdSpy.mockRestore(); + } + }); + + it('does not register the per-user library', async () => { + // It always exists, so listing it would be noise. + const home = await tempHome(); + const plain = await tempPlainDir(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(plain); + const context = await createContext({ HOME: home }, () => {}); + try { + expect(context.paths.isProjectLibrary).toBe(false); + expect(existsSync(registryPath(join(home, '.local', 'share', 'ailoud')))).toBe(false); + } finally { + context.store.close(); + cwdSpy.mockRestore(); + } + }); + + it('registers the project mcp install wrote rules into, with the version', async () => { + const ctx = fakeCliContext(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/proj/a'); + try { + await buildProgram(ctx).parseAsync([ + 'node', + 'ailoud', + 'mcp', + 'install', + '--yes', + '--target', + 'claude', + '--location', + 'local', + ]); + const raw = await ctx.fs.readTextFile(registryPath(ctx.paths.userDataDir)); + const entries = JSON.parse(raw) as Array<{ path: string; rulesVersion?: string }>; + const entry = entries.find((candidate) => candidate.path === '/proj/a'); + expect(entry?.rulesVersion).toBe(VERSION); + } finally { + cwdSpy.mockRestore(); + } + }); + + it('registers at most once a day', async () => { + // The hot-path rule: `createContext` runs before every command, and + // writing the registry on every single one of them would put a disk + // write on something as routine as `ailoud ls`. + const home = await tempHome(); + const project = await tempProject(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(project); + const writeSpy = vi.spyOn(NodeFs.prototype, 'writeTextFile'); + try { + const first = await createContext({ HOME: home }, () => {}); + first.store.close(); + const second = await createContext({ HOME: home }, () => {}); + second.store.close(); + + const registryWrites = writeSpy.mock.calls.filter(([path]) => path.includes('projects.json')); + expect(registryWrites).toHaveLength(1); + } finally { + cwdSpy.mockRestore(); + writeSpy.mockRestore(); + } + }); + + it('never fails a command because the registry could not be written', async () => { + // `ailoud ls` (or any other command) must not die because a bookkeeping + // file could not be written -- a full disk, a read-only home, or any + // other permission problem. + const home = await tempHome(); + const project = await tempProject(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(project); + const writeSpy = vi + .spyOn(NodeFs.prototype, 'writeTextFile') + .mockRejectedValue(new Error('ENOSPC: no space left on device')); + try { + const context = await createContext({ HOME: home }, () => {}); + try { + expect(context.paths.isProjectLibrary).toBe(true); + } finally { + context.store.close(); + } + } finally { + cwdSpy.mockRestore(); + writeSpy.mockRestore(); + } + }); +}); + +describe('resource budget', () => { + const dirs: string[] = []; + + afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); + }); + + /** + * A config that leaves both diarization models set, so `createDiarizer` + * gets past its own missing-model checks and reaches the threads + * resolution this suite cares about. Only the `diarization:` block a test + * passes in is appended below it. + */ + const DIARIZATION_MODELS = + 'stt:\n' + + ' diarization:\n' + + ' segmentationModel: /models/segmentation.onnx\n' + + ' embeddingModel: /models/embedding.onnx\n'; + + /** A config that leaves the VAD model set, so `createSegmenter` gets past its own missing-model check. */ + const VAD_MODEL = 'stt:\n whisperCpp:\n vadModel: /models/vad.onnx\n'; + + async function makeContext(config: string): Promise { + const home = await mkdtemp(join(tmpdir(), 'ailoud-wiring-budget-')); + dirs.push(home); + await mkdir(join(home, '.config', 'ailoud'), { recursive: true }); + await writeFile(join(home, '.config', 'ailoud', 'config.yaml'), config); + return createContext({ HOME: home }, () => {}); + } + + /** + * Reads the thread count an adapter was constructed with. The four engine + * option types keep `options` as a private class field, which TypeScript + * enforces only at the type level -- the object is a plain property at + * runtime, so a cast to a narrow, unrelated shape reads it back without + * reaching for `any`. + */ + function threadsOf(engine: unknown): number { + return (engine as { readonly options: { readonly threads: number } }).options.threads; + } + + it('gives the diarizer and the VAD a smaller share than the ceiling on a hybrid cpu', () => { + // The regression this whole feature turns on: 90 percent of 8 + // performance cores is 7 threads, and both engines are measurably slower + // at 7 than at 6. + const budget = resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: 90 }); + expect(budget.threads).toBe(7); + expect(budget.cappedThreads).toBe(6); + }); + + it('gives the segmenter the same capped share as the diarizer, not the full ceiling', async () => { + // The VAD has the same measured optimum as the diarizer (see budget.ts), + // so createSegmenter must be wired to cappedThreads too, not to threads. + const context = await makeContext(VAD_MODEL); + try { + const segmenter = context.createSegmenter( + resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: 100 }), + ); + expect(threadsOf(segmenter)).toBe(6); + } finally { + context.store.close(); + } + }); + + it('lets an explicit config thread count override the budget', async () => { + const context = await makeContext(`${DIARIZATION_MODELS} threads: 3\n`); + try { + const diarizer = context.createDiarizer( + resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: 100 }), + ); + expect(threadsOf(diarizer)).toBe(3); + } finally { + context.store.close(); + } + }); + + it('follows the budget when the config leaves threads null', async () => { + const context = await makeContext(DIARIZATION_MODELS); + try { + const diarizer = context.createDiarizer( + resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: 100 }), + ); + expect(threadsOf(diarizer)).toBe(6); + } finally { + context.store.close(); + } + }); +}); diff --git a/apps/cli/src/wiring.ts b/apps/cli/src/wiring.ts index ac35d5a..eca55de 100644 --- a/apps/cli/src/wiring.ts +++ b/apps/cli/src/wiring.ts @@ -9,15 +9,21 @@ import type { SpeechSegmenter, Summarizer, TranscriptionProvider, + VersionSource, } from '@ailoud/core'; import { existsSync, statSync } from 'node:fs'; -import { EnvironmentError } from '@ailoud/core'; +import { EnvironmentError, isHostedLlm, resourceBudget } from '@ailoud/core'; +import type { ResourceBudget } from '@ailoud/core'; import { AnthropicSummarizer, ClaudeCliSummarizer, + cpuTopology, + DEFAULT_REGISTRY, + DEFAULT_TIMEOUT_MS, FfmpegAudioTool, LlamaCppSummarizer, NodeFs, + NpmRegistry, OpenAiCompatibleSummarizer, SherpaDiarizer, SystemClock, @@ -26,11 +32,58 @@ import { WhisperVadSegmenter, openStore, } from '@ailoud/providers'; -import { parseConfig, resolvePaths } from './config.js'; +import { PROJECT_DIR, parseConfig, resolvePaths } from './config.js'; +import type { RegistryTransport } from '@ailoud/providers'; import type { AiloudConfig, AiloudPaths } from './config.js'; import { createUi } from './ui/index.js'; import type { Ui } from './ui/index.js'; import { apiKeyFrom } from './apiKey.js'; +import { rememberProject } from './projects.js'; + +/** + * Where `ailoud self check` looks for newer versions, and how long it waits. + * Not read from AiloudConfig: the schema's `update.check` key is only + * whether to look, never where -- there is one npm registry this project + * publishes to, and no user has a reason to point ailoud at another one. + */ +const UPDATE_REGISTRY = DEFAULT_REGISTRY; +const UPDATE_TIMEOUT_MS = DEFAULT_TIMEOUT_MS; + +/** + * Stubs the npm registry answer with a JSON fixture, when `AILOUD_PACKUMENTS` + * names one, instead of a real network call. + * + * Same environment variable and the same fixture shape -- + * `{ "": }` -- that `scripts/retire-prereleases.mjs` + * reads for the identical reason: a file an end-to-end test can point at, + * never a server a test could leave open. A thrown test there once skipped + * the server's own `close()`, and the leaked handle hung the whole suite + * with no failing test to point at, because a per-test timeout does not + * apply to a handle nobody closed. This reads a file instead, on every call, + * so there is never a handle to leak. + */ +function packumentFixtureTransport(fixturePath: string): RegistryTransport { + // Announced on stderr, every run, deliberately. This hook ships INSIDE the + // binary -- unlike the identical variable in scripts/retire-prereleases.mjs, + // which only maintainers run -- so it can substitute where `self check` and + // `self update` get their version facts. Nobody can set it in your + // environment without already being able to do worse, and it cannot cause a + // bad install because the install itself still resolves against the real + // registry. What it CAN do is hide that an update exists. A silent + // substitution of trusted data is the part worth refusing, so it is made + // impossible: if this is in effect, you are told. + process.stderr.write( + `ailoud: reading npm versions from the fixture ${fixturePath} (AILOUD_PACKUMENTS is set), not from the registry\n`, + ); + return async (url) => { + const name = decodeURIComponent(new URL(url).pathname.slice(1)); + const raw = await readFile(fixturePath, 'utf8'); + const all = JSON.parse(raw) as Record; + const packument = all[name]; + if (packument === undefined) return { status: 404, body: '' }; + return { status: 200, body: JSON.stringify(packument) }; + }; +} export interface CliContext { readonly paths: AiloudPaths; @@ -48,6 +101,15 @@ export interface CliContext { */ readonly write: (line: string) => void; readonly ui: Ui; + /** + * How much of this machine each engine may take, from config plus any + * per-run overrides. Reads the topology once per process (cpuTopology + * memoises), so calling this per command is free after the first. + */ + resources(overrides?: { + readonly maxCpuPercent?: number; + readonly gpu?: boolean; + }): Promise; /** * Builds the transcription provider on demand instead of at context * construction. `createContext` runs before every command, including @@ -58,7 +120,7 @@ export interface CliContext { * provider, so it is the one that pays for this call failing when the * model is missing. */ - createStt(): TranscriptionProvider; + createStt(budget?: ResourceBudget): TranscriptionProvider; /** * Builds the VAD speech segmenter on demand, for the same reason * `createStt` does: `createContext` runs before every command, including @@ -68,13 +130,13 @@ export interface CliContext { * segmenter, so it is the one that pays for this call failing when the * model is missing. */ - createSegmenter(): SpeechSegmenter; + createSegmenter(budget?: ResourceBudget): SpeechSegmenter; /** * The configured large language model. Throws an EnvironmentError naming * what is missing rather than returning null, so a command need not decide * how to explain a half-configured engine. */ - createSummarizer(): Summarizer; + createSummarizer(budget?: ResourceBudget): Summarizer; /** * Builds the speaker diarizer on demand, for the same reason `createStt` * and `createSegmenter` do: `createContext` runs before every command, @@ -84,7 +146,23 @@ export interface CliContext { * so it is the one that pays for this call failing when a model is * missing. */ - createDiarizer(): Diarizer; + createDiarizer(budget?: ResourceBudget): Diarizer; + /** + * What versions of ailoud are published, for `ailoud self check`. A port, + * not `NpmRegistry` directly, the same way every other engine on this + * context is: `createContext` is the only place that knows which provider + * backs it. + */ + readonly versionSource: VersionSource; + /** + * The registry host and timeout `versionSource` was built with. Kept + * alongside it rather than read back off it: `VersionSource` only + * promises `published()`, so a failed lookup could not otherwise name + * where it looked or how long it waited before giving up -- exactly what + * a check that could not run must report. + */ + readonly updateRegistryHost: string; + readonly updateTimeoutMs: number; } async function readConfigFile(path: string): Promise { @@ -95,6 +173,37 @@ async function readConfigFile(path: string): Promise { } } +/** + * Records that this run resolved a project's own library, so a later + * `ailoud self sync` has something to sweep. The per-user library is never + * entered here: it always exists, so listing it in the registry would only + * ever be noise. + * + * Registration is bookkeeping, not the user's request -- `createContext` runs + * before every command, including `doctor` and `ls`, and a full disk, a + * read-only home, or any other write failure here must never fail the + * command that triggered it. Any error is swallowed down to a single debug + * line. + */ +async function registerProjectLibrary(fs: Fs, clock: Clock, paths: AiloudPaths): Promise { + if (!paths.isProjectLibrary) return; + // `paths.dataDir` IS the project's `.ailoud/` in this branch (see + // `AiloudPaths.dataDir`'s own doc comment), so the project itself is that + // directory with the trailing `/.ailoud` stripped back off. + const projectPath = paths.dataDir.slice(0, -`/${PROJECT_DIR}`.length); + try { + await rememberProject( + { fs, clock, userDataDir: paths.userDataDir }, + { path: projectPath, libraryDir: paths.dataDir }, + ); + } catch (error) { + process.stderr.write( + `ailoud: debug: could not register project "${projectPath}": ` + + `${error instanceof Error ? error.message : String(error)}\n`, + ); + } +} + export async function createContext( env: Record, write: (line: string) => void = (line) => process.stdout.write(`${line}\n`), @@ -108,17 +217,30 @@ export async function createContext( const raw = await readConfigFile(paths.configFile); const config = parseConfig(raw); await mkdir(paths.mediaRoot, { recursive: true }); + const fs = new NodeFs(); + const clock = new SystemClock(); + await registerProjectLibrary(fs, clock, paths); return { paths, config, store: openStore(paths.dbFile), - fs: new NodeFs(), + fs, audio: new FfmpegAudioTool(), - clock: new SystemClock(), + clock, ids: new UlidIds(), write, ui: createUi(write), - createStt(): TranscriptionProvider { + async resources(overrides = {}): Promise { + return resourceBudget(await cpuTopology(), { + maxCpuPercent: overrides.maxCpuPercent ?? config.resources.maxCpuPercent, + gpu: overrides.gpu ?? config.resources.gpu, + }); + }, + // The literal `4` in each factory's fallback below is whisper's, the + // VAD's and llama's own default, and the diarizer's previous config + // default -- so a caller that passes no budget behaves exactly as the + // code did before this feature. + createStt(budget?: ResourceBudget): TranscriptionProvider { const model = config.stt.whisperCpp.model; if (model === null) { throw new EnvironmentError( @@ -126,9 +248,14 @@ export async function createContext( `${paths.configFile} to the path of a model file; run "ailoud doctor" for details.`, ); } - return new WhisperCppProvider({ binary: config.stt.whisperCpp.binary, modelPath: model }); + return new WhisperCppProvider({ + binary: config.stt.whisperCpp.binary, + modelPath: model, + threads: budget?.threads ?? 4, + gpu: budget?.gpu ?? config.resources.gpu, + }); }, - createSummarizer(): Summarizer { + createSummarizer(budget?: ResourceBudget): Summarizer { const llm = config.llm; if (llm.provider === 'claude-cli') { @@ -165,7 +292,7 @@ export async function createContext( if (llm.provider === 'openai-compatible') { const settings = llm.openaiCompatible; const apiKey = apiKeyFrom(env, 'OPENAI_API_KEY'); - if (settings.baseUrl.startsWith('https://api.openai.com') && apiKey === undefined) { + if (isHostedLlm(settings.baseUrl) && apiKey === undefined) { throw new EnvironmentError( 'No API key for the language model. Set AILOUD_LLM_API_KEY (or OPENAI_API_KEY) in ' + 'your environment. It is read from the environment on purpose and never from ' + @@ -193,11 +320,11 @@ export async function createContext( modelPath: settings.model, contextTokens: settings.contextTokens, maxOutputTokens: settings.maxOutputTokens, - threads: settings.threads, + threads: settings.threads ?? budget?.threads ?? 4, }); }, - createSegmenter(): SpeechSegmenter { + createSegmenter(budget?: ResourceBudget): SpeechSegmenter { const vadModel = config.stt.whisperCpp.vadModel; if (vadModel === null) { throw new EnvironmentError( @@ -208,9 +335,12 @@ export async function createContext( return new WhisperVadSegmenter({ binary: config.stt.whisperCpp.vadBinary, vadModelPath: vadModel, + // The capped share, not the full ceiling: measured to the same + // optimum as the diarizer (see budget.ts's ResourceBudget.cappedThreads). + threads: budget?.cappedThreads ?? 4, }); }, - createDiarizer(): Diarizer { + createDiarizer(budget?: ResourceBudget): Diarizer { const segmentationModel = config.stt.diarization.segmentationModel; if (segmentationModel === null) { throw new EnvironmentError( @@ -232,8 +362,20 @@ export async function createContext( segmentationModel, embeddingModel, threshold: config.stt.diarization.threshold, - threads: config.stt.diarization.threads, + // Config wins where it is set: an explicit number is a measurement + // someone made on their own machine, and it is exempt from the cap. + // Null means follow the budget's capped share. + threads: config.stt.diarization.threads ?? budget?.cappedThreads ?? 4, }); }, + versionSource: new NpmRegistry({ + registry: UPDATE_REGISTRY, + timeoutMs: UPDATE_TIMEOUT_MS, + ...(env['AILOUD_PACKUMENTS'] === undefined || env['AILOUD_PACKUMENTS'] === '' + ? {} + : { transport: packumentFixtureTransport(env['AILOUD_PACKUMENTS']) }), + }), + updateRegistryHost: new URL(UPDATE_REGISTRY).host, + updateTimeoutMs: UPDATE_TIMEOUT_MS, }; } diff --git a/docs/development/architecture.md b/docs/development/architecture.md index d2765be..f5c8842 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -40,6 +40,13 @@ the domain, the ports, and pure logic. Swapping an engine means writing one adapter. Nothing in `core` changes. +Resource limits are computed once per command in `apps/cli/src/wiring.ts` and +handed to each engine adapter. The arithmetic is a pure function in +`packages/core/src/resources/budget.ts`, so core keeps doing no I/O; reading +the CPU topology is a provider. Engines do not all get the same number: +speaker diarization is measurably slower past a lower thread count than +whisper, so it gets a capped share of the same ceiling. + ## Database SQLite through `node:sqlite`, with `PRAGMA foreign_keys = ON`. @@ -59,16 +66,16 @@ only ever move forward. ## The summary prompt -The prompt is measured, not guessed. `scripts/eval-summary-prompt.mjs` runs -variants three times each over seven transcripts -- English, Russian, -code-switched, long, multi-recording, undiarized, and a language override -- -across haiku, sonnet and opus, scoring each run for stated facts, invented -ones, language and length. - ``` node scripts/eval-summary-prompt.mjs --runs 3 node scripts/eval-summary-prompt.mjs --models haiku --cases one-on-one ``` +The prompt is measured, not guessed. Each variant runs three times over eight +transcripts -- English, Russian, code-switched, long, multi-recording, +one-on-one, undiarized, and a language override -- across haiku, sonnet and +opus. Every run +is scored for stated facts, invented facts, language and length. + Change the prompt or a template's headings, then re-run it. The previous measurement does not carry over. diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 3cd0233..f47ae43 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -10,10 +10,16 @@ ## Tags -| Tag | Means | -| ------------------- | ---------------------------------------- | -| `v-rc.` | a release candidate, tagged on `develop` | -| `v` | a final release, tagged on `main` only | +| Tag | Means | npm dist-tag | +| -------------------- | ---------------------------------------- | ------------ | +| `v-dev.` | a snapshot, tagged on any branch | `dev` | +| `v-rc.` | a release candidate, tagged on `develop` | `next` | +| `v` | a final release, tagged on `main` only | `latest` | + +Only a final tag moves `latest`, so `npm install ailoud` never returns a +pre-release -- except for a package's very first publish, where npm sets +`latest` whatever `--tag` says. `latest` can be moved but not removed, so that +first pre-release answers `npm install ailoud` until a final version exists. ## Changelog limits @@ -28,25 +34,50 @@ bug, there is nothing to say. See ## Steps 1. Run the `pre-release-check` skill. It runs the whole gate plus the - documentation, changelog and version checks. + documentation, dependency, changelog and version checks. + 2. Run the `bump-version` skill. It sets the version across every - `package.json`, promotes the CHANGES.md Development section, and makes the - release commit. It does not tag or push. -3. Extract the release body: + `package.json` and promotes the CHANGES.md Development section. It does not + tag or push. + +3. Fold the pre-release sections in. `bump-version` comes first: ``` - node scripts/release-notes.mjs v1.2.3 + node scripts/fold-prereleases.mjs 1.2.3 + node scripts/check-changelog.mjs v1.2.3 ``` - It reads the `## Version 1.2.3` section and writes `RELEASE_NOTES.md`. It - exits non-zero if that section is missing, empty, or over the hard limit. + Folding first would leave `bump-version` an empty Development section to + promote, giving a second `## Version 1.2.3` heading that fails the check. -4. Merge to `main` and tag: + To read the notes as they will appear, `node scripts/release-notes.mjs +v1.2.3` writes them to `RELEASE_NOTES.md`. The release itself does not need + this -- `publish.yml` runs the same script. + +4. Commit, tag and push: ``` + git commit -am "chore: 1.2.3" git tag -s v1.2.3 -m "v1.2.3" - git push origin main --tags + git push origin main + git push origin v1.2.3 + ``` + + The tag is what starts everything else: `publish.yml` publishes the three + packages and creates the GitHub release from CHANGES.md; `docs.yml` then + publishes the site. Retiring the superseded snapshots is the one step left + to a human -- see below. + +5. Retire the snapshots this release supersedes, by hand: + ``` + pnpm retire 1.2.3 # prints the plan + NPM_TOKEN=npm_... pnpm retire 1.2.3 --yes # carries it out + ``` + + Deprecates every pre-release of that version -- `1.2.3-dev.*` and + `1.2.3-rc.*` alike -- moves the `dev` dist-tag onto the release, + and deletes the tags. See "Retiring pre-releases" below. ## Publishing to npm @@ -59,6 +90,17 @@ mints a short-lived OIDC token for the run, npm exchanges it for a credential good for minutes, and provenance is attached automatically. Nothing long-lived is stored, so there is no 90-day expiry to renew. +Except once, per package: a trusted publisher cannot be attached to a package +that does not exist yet, so the first version of each goes out on a token in +the `NPM_TOKEN` secret. The workflow uses the secret when present and OIDC when +not, so deleting the secret is the whole of the switch. + +It will not let that drift: a **pre-release** published on the token logs a +warning, and a **final release** with the secret still set fails before +publishing anything. Attaching the publisher (organization `lorem-dev`, +repository `ailoud`, workflow `publish.yml`, environment empty) on all three +package pages and deleting the secret clears it. + One-time setup on npmjs.com, per package -- Package, then Settings, then Trusted publisher, then GitHub Actions: @@ -74,24 +116,57 @@ half of what is needed: `pnpm pack` rewrites the `workspace:*` dependencies into real versions, which npm requires and will not do itself, and `npm publish` is the one with OIDC and provenance. -Before publishing it checks that all three manifests agree with the tag, then -runs the whole gate. A pre-release tag publishes under the `next` dist-tag, so -`npm install ailoud` keeps returning the last stable version. +Before publishing, and before anything is built, it refuses a release that +should not happen: a changelog unfit to release, an open high or critical code +scanning alert, a manifest that disagrees with the tag, or the `NPM_TOKEN` +secret still set on a final tag. Then it runs the whole gate. + +A code scanning finding blocks only while it is open. One that has been +reviewed is dismissed with its reason and does not block. + +## Retiring pre-releases + +After a final release, retire the snapshots it supersedes: + +``` +pnpm retire 1.0.0 # prints the plan +NPM_TOKEN=npm_... pnpm retire 1.0.0 --yes # carries it out +``` + +Run it after every final release. Nothing prompts for it, and until it runs +`npm install ailoud@dev` hands out an older build than `npm install ailoud`. +`NPM_TOKEN` is a granular access token with read-and-write on the three +packages; without it npm asks for a 2FA code on each of the twelve writes. + +Deprecating, not unpublishing: a deprecated version keeps every pinned install +working and prints a notice on the next one. It also drops the `dev` dist-tag, +and deletes the tags -- but only those whose commit is reachable from `main`, +because the published provenance attests that commit. The rest are reported and +left in place. + +This is a manual step, run under `npm login`: trusted publishing authenticates +`npm publish` and nothing else, so the token it returns cannot deprecate. + +If the npm side does not complete, the script leaves the tags alone and exits +non-zero. The tags are what name which pre-releases to retire, so deleting them +after a failed deprecation would destroy the only record of what was missed. ## What a tag triggers -Pushing a final tag runs -[`.github/workflows/docs.yml`](https://github.com/lorem-dev/ailoud/blob/main/.github/workflows/docs.yml), -which publishes the documentation for that version to the `gh-pages` branch -with [mike](https://github.com/jimporter/mike) and moves the `latest` alias -that the site root redirects to. +Pushing a tag runs `publish.yml`. When it succeeds, +[`.github/workflows/docs.yml`](https://github.com/lorem-dev/ailoud/blob/main/.github/workflows/docs.yml) +runs on its completion and publishes the documentation for that version to the +`gh-pages` branch with [mike](https://github.com/jimporter/mike), moving the +`latest` alias that the site root redirects to. `publish.yml` also creates the +GitHub release, with the body taken from the `## Version ` section of +CHANGES.md by `scripts/release-notes.mjs`. Nothing else publishes documentation. A push to a branch publishes nothing, so what is online always describes a version someone can install. -A pre-release tag (`v1.2.3-rc.1`, or any tag with a `-` qualifier) publishes -nothing either. The workflow refuses it twice: the tag filter never starts it, -and the job checks again in case `workflow_dispatch` was pointed at one. +A pre-release publishes nothing either. It reaches docs.yml -- `publish.yml` +runs for pre-releases too -- and the job stops once it reads the version from +the published commit's manifest and finds a `-` in it. ## One-time repository setup diff --git a/docs/getting-started.md b/docs/getting-started.md index 07e5514..13ed557 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -40,6 +40,10 @@ For an unattended run: ailoud setup --yes --llm local ``` +Downloads `large-v3-turbo-q5_0` by default. `--model small` switches later, and `--force` +reinstalls everything even on a machine that already checks out fine -- see +the [CLI reference](usage/cli.md#setup) for both. + `setup` asks which language model to use for summaries. Pick one: | Choice | Needs | diff --git a/docs/index.md b/docs/index.md index 505d7ae..262eda5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,10 +16,10 @@ ailoud audio summarize ID001 --template one-on-one ## Start here - [Getting Started](getting-started.md) -- install it, transcribe your first file. +- [MCP](mcp.md) -- let an agent use your library. - [Recordings](usage/recordings.md) -- import, transcribe, tag, annotate. - [Search](usage/search.md) -- find where something was said. - [Summaries](usage/summaries.md) -- make reports and read them back. -- [MCP](mcp.md) -- let an agent use your library. ## What it is good at diff --git a/docs/mcp.md b/docs/mcp.md index c8c8a31..bf52926 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,36 +73,61 @@ Your own files are safe: is not JSON at all is refused with a message rather than rewritten. !!! note -Comments in a JSON or `.jsonc` MCP config do not survive an edit -- the file -is parsed and re-serialised. TOML and YAML keep theirs. -Your own config files are safe: - -- Only the text between the two markers is ever rewritten. A file that merely - _mentions_ `` in prose keeps everything around it. -- A `config.toml` that already defines an `ailoud` server some other way is - refused, not edited. Two definitions of one key is a TOML error, and it - would break your whole Codex config rather than just this server. -- A Hermes `config.yaml` with your own settings or comments is rewritten, never - deleted. Only a file holding nothing but AILoud's own keys is removed. -- A trailing comma or a byte-order mark in a `.jsonc` is tolerated. A file that - is not JSON at all is refused with a message, not rewritten. - -!!! note -Comments in a JSON or `.jsonc` MCP config do not survive an edit -- the -file is parsed and re-serialised. TOML and YAML keep theirs. + Comments in a JSON or `.jsonc` MCP config do not survive an edit -- the + file is parsed and re-serialised. TOML and YAML keep theirs. ### Supported agents | Agent | Scopes | Config | Rules file | | ---------- | --------------- | ------------------------------ | ------------------------------------ | -| `claude` | project, global | `.mcp.json` / `~/.claude.json` | `CLAUDE.md` | +| `claude` | project, global | `.mcp.json` / `~/.claude.json` | `.claude/CLAUDE.md`, `CLAUDE.md` | | `codex` | project, global | `.codex/config.toml` | `AGENTS.md` | | `opencode` | project, global | `opencode.jsonc` | `AGENTS.md` | | `gemini` | project, global | `.gemini/settings.json` | `GEMINI.md` | | `hermes` | global only | `~/.hermes/config.yaml` | `~/.hermes/AGENTS.md` | | `copilot` | global only | `~/.copilot/mcp-config.json` | `~/.copilot/copilot-instructions.md` | +## Running `ailoud` without an approval prompt + +The rules block tells an agent to reach for `ailoud audio search` and its +neighbours. Most agents ask for approval before running a command, every time. +`mcp install` offers to add `ailoud` to the agent's allow-list so it does not +have to. + +The prompt appears during an interactive install, after the location question, +and lists the exact files it would edit. `--allow-shell` and `--no-allow-shell` +answer it without a prompt. + +`-y` on its own grants nothing. It means "do not prompt", and an unasked +permission question is not the same as one answered yes. Use `-y --allow-shell` +to ask for the allow-list in a script. + +| Agent | File | Entry | +| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------- | +| Claude Code | `.claude/settings.json`, or `~/.claude/settings.json` | `permissions.allow: ["Bash(ailoud:*)"]` | +| Codex CLI | `~/.codex/policy.yaml` | `allow: ["ailoud", "ailoud *"]` | +| opencode | `opencode.jsonc`, or `~/.config/opencode/opencode.jsonc` | `permission.bash: {"ailoud": "allow", "ailoud *": "allow"}` | +| Gemini CLI | `.gemini/settings.json`, or `~/.gemini/settings.json` | `tools.allowed: ["run_shell_command(ailoud)"]` | +| GitHub Copilot CLI | `~/.copilot/permissions-config.json` | a `commands` approval for this directory | +| Hermes Agent | -- | Hermes records approvals itself; nothing to write | + +Codex keeps one policy file for the machine even for a per-project install, and +Copilot scopes its approval to the directory you ran the install in -- which +the install says on its own line, because the file it writes is machine-wide. + +!!! note + + The rewrite rule above applies to these files too: comments in + `.claude/settings.json`, `.gemini/settings.json`, `opencode.jsonc` and + `~/.copilot/permissions-config.json` do not survive an edit. Codex's + `policy.yaml` keeps its comments, including any written inside the allow + list itself. + +`mcp uninstall` removes the entry. `mcp update` refreshes one that is already +there and never adds one, which is why `ailoud self sync` cannot widen an +agent's permissions while sweeping your projects. + ## The project library A directory named `.ailoud/` makes that project's recordings separate from @@ -123,21 +148,10 @@ model files, which are not a property of a project. ## By hand -=== "Claude Code" - - `.mcp.json` in your project, or `~/.claude.json` for every project: - - ```json - { - "mcpServers": { - "ailoud": { "command": "ailoud", "args": ["mcp"] } - } - } - ``` - -=== "Claude Desktop" +=== "Claude Code / Claude Desktop" - `claude_desktop_config.json`: + `.mcp.json` in your project, `~/.claude.json` for every project, or + `claude_desktop_config.json` for Claude Desktop: ```json { @@ -173,8 +187,7 @@ Check it works: echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' | ailoud mcp ``` -It serves the same library the CLI uses. Anything you import in the shell is -visible to the agent, and the other way round. +It serves the same library the CLI uses, in both directions. ## Ask it things @@ -199,16 +212,17 @@ Tag the untagged recordings for me. | `list_reports` | saved summaries | | `get_report` | a **file path** | | `list_templates` | the summary shapes available | +| `job_status` | a `transcribe` or `summarize` job's state | **Writing** -| Tool | Does | -| ------------------ | ---------------------------------- | -| `annotate` | titles, notes, tags, speaker names | -| `import_recording` | adds files to the library | -| `transcribe` | runs speech-to-text | -| `summarize` | writes and saves a report | -| `create_template` | adds a summary shape | +| Tool | Does | +| ------------------ | ---------------------------------------- | +| `annotate` | titles, notes, tags, speaker names | +| `import_recording` | adds files to the library | +| `transcribe` | starts speech-to-text, in the background | +| `summarize` | starts a report, in the background | +| `create_template` | adds a summary shape | **Deleting** @@ -217,9 +231,96 @@ Tag the untagged recordings for me. | `delete_recording` | two calls; see [below](#deleting-takes-two-calls) | | `delete_report` | two calls | +## Background jobs + +### `transcribe` refuses without speakers and languages + +The first call without them gets this instead of a job id: + +```json +{ + "error": "transcribe needs the speaker count and the expected languages", + "why": "declared languages stop whisper reporting Polish for a Russian stretch, which then comes back as phonetic nonsense; a known speaker count is more reliable than letting the diarizer infer one", + "guess": { "languages": ["ru", "en"], "from": "filename \"standup-ru-en.wav\"" }, + "ask": "Ask the user how many people speak on this recording and in which languages. Offer the guess above, plus your own reading of the name, and let them correct it. Ask per recording when the recordings differ.", + "then": "call transcribe again with speakers and languages" +} +``` + +`guess` is `null` when the recording's name, title and tags give no hint. +`speakers` accepts a positive integer or `"unknown"`; `languages` accepts +codes such as `["ru", "en"]` or `["auto"]`. + +### The job cycle + +`transcribe` and `summarize` return at once, with a job id to poll: + +``` +transcribe(recordingIds: [...], speakers: 2, languages: ["ru", "en"]) +-> { "jobId": "01M1Y5F04PS6VQ0FCP8HAS2JZ9", "kind": "transcribe", + "poll": "call job_status with this id; a few minutes apart is often enough" } + +job_status(jobId: "01M1Y5F04PS6VQ0FCP8HAS2JZ9") +-> { "state": "running", "percent": 46, "stage": "detecting", ... } +``` + +Poll every minute or two; polling faster does not make the work finish sooner. +With no `jobId`, `job_status` lists what is running plus the five most recent +finished jobs. + +A finished transcription that diarized speakers reports the labels nobody has +named yet, so an agent can offer to name them while the transcript is in front +of it: + +``` +job_status(jobId: "01M1Y5F04PS6VQ0FCP8HAS2JZ9") +-> { "state": "done", "unnamedSpeakers": [ + { "recordingId": "01M1...", "labels": ["speaker_00", "speaker_01"] }], + "nextStep": "... record it with `annotate` (speakerNames) ..." } +``` + +Only a person knows which label is which. A name given once survives +re-transcription and is used by every later summary. + +An id `job_status` does not recognise comes back on the same `isError` channel +a thrown refusal uses, but the payload itself distinguishes it from a job that +ran and failed -- `error` names the id as unrecognised rather than describing +a failure, and `hint` says what to do next: + +```json +{ "error": "no such job: nosuchjob", "hint": "call job_status with no id to list" } +``` + +The state document: + +| Field | Meaning | +| ------------ | ----------------------------------------------------------------------- | +| `id` | the job id | +| `kind` | `transcribe` or `summarize` | +| `state` | `running`, `done` or `failed` | +| `percent` | 0-100, approximate, never goes backwards | +| `stage` | what it is doing right now, e.g. `detecting`, `transcribing` | +| `etaSeconds` | present once there is enough of the run to estimate from | +| `pid` | the process id doing the work; `job rm` names it if it is still running | +| `recordings` | `{ total, done }` | +| `declared` | the speakers and languages given to `transcribe`, or null | +| `startedAt` | when the job began, ISO 8601 | +| `finishedAt` | when it ended, ISO 8601, or null while running | +| `log` | a **file path**, not the log text | +| `result` | set on success; a finished `summarize` carries `reportId` | +| `error` | one message, set on failure | + +`log` is a path because it is a growing trail of stage transitions and +warnings over what can be an hour-long run, and it is only worth reading +after something fails -- and even then, `error` above already carries the +failure message. + +A finished `summarize` job's `result` carries a `reportId`; read it with +`get_report`. + ## How it behaves -The server tells the agent four rules before its first call. +The server tells the agent six rules before its first call. **Tag everything.** Tags are the only way to ask for "the recordings about this project". `list_recordings` flags untagged ones and counts them, and @@ -233,10 +334,18 @@ tokens. returns the path, the line count and the duration. The agent reads the part it needs with its own tools. The directory is removed when the server stops. +**Pick a template for a summary.** `list_templates` shows the shapes on +offer; the default meeting shape answers a one-to-one badly, so check the +list before summarising. + **Context lives in the agent's memory.** `summarize` takes a short `context`; AILoud does not remember it between calls. The agent keeps it and passes it again. +**Transcribing refuses without speakers and languages.** `transcribe` will +not start until the agent declares both; see [Background +jobs](#background-jobs) for the refusal itself and what it returns instead. + ## Deleting takes two calls The first call deletes nothing. It describes what would go and returns a diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 7a343e9..ae0f2ea 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -15,6 +15,7 @@ ailoud audio summarize --help ``` ailoud audio|recordings import transcribe summarize search ls show annotate rm ailoud report|reports ls show rm +ailoud job|jobs ls show rm ailoud template|templates ls show new ailoud mcp ailoud doctor @@ -44,7 +45,7 @@ ailoud report l The old top-level spellings still work: `ailoud ls`, `ailoud show`, `ailoud rm`, `ailoud annotate`, `ailoud import`, `ailoud transcribe`, -`ailoud summarize`. +`ailoud summarize`, `ailoud search`. ## audio import @@ -64,15 +65,19 @@ ailoud audio import [--title ] [--notes ] [--tag ] ailoud audio transcribe [ids...] [options] ``` -| Option | Does | -| ---------------- | ------------------------------------------------------- | -| `--lang ` | `ru`, or `ru,en` for several, or `auto` | -| `--model ` | override the configured model | -| `--force` | re-transcribe recordings that already have a transcript | -| `--multilingual` | segment by speech and language, transcribe each run | -| `--diarize` | attribute segments to speakers | -| `--speakers ` | known number of speakers | -| `--tag ` | tag these recordings; repeatable | +| Option | Does | +| --------------------- | ------------------------------------------------------- | +| `--lang ` | `ru`, or `ru,en` for several, or `auto` | +| `--model ` | override the configured model | +| `--force` | re-transcribe recordings that already have a transcript | +| `--multilingual` | segment by speech and language, transcribe each run | +| `--diarize` | attribute segments to speakers | +| `--speakers ` | known number of speakers | +| `--tag ` | tag these recordings; repeatable | +| `--max-cpu ` | share of this machine to use, 1 to 100 | +| `--no-gpu` | do not use the GPU, even where a binary supports it | +| `--denoise ` | `auto`, `on` or `off` | +| `--detach` | start the work in the background and print a job id | With no ids, transcribes everything that has no transcript yet. @@ -97,14 +102,16 @@ ailoud audio search [options] ailoud audio summarize [ids...] [options] ``` -| Option | Does | -| ------------------- | -------------------------------------------------- | -| `--tag ` | summarise everything carrying this tag; repeatable | -| `--template ` | which shape; see `ailoud template ls` | -| `--context ` | a sentence the transcript does not say | -| `--lang ` | write the summary in this language | -| `--fresh` | re-read transcripts instead of stored reports | -| `--no-save` | do not store the summary | +| Option | Does | +| --------------------- | --------------------------------------------------- | +| `--tag ` | summarise everything carrying this tag; repeatable | +| `--template ` | which shape; see `ailoud template ls` | +| `--context ` | a sentence the transcript does not say | +| `--lang ` | write the summary in this language | +| `--fresh` | re-read transcripts instead of stored reports | +| `--no-save` | do not store the summary | +| `--max-cpu ` | share of this machine to use, 1 to 100 | +| `--detach` | start the work in the background and print a job id | ## audio ls @@ -154,6 +161,27 @@ ailoud report show [--json] ailoud report rm [--force] ``` +## job + +``` +ailoud job ls [--json] +ailoud job show [--json] +ailoud job rm +``` + +| Verb | Letter | Does | +| ------ | ------ | --------------------------------------------- | +| `ls` | `l` | list background jobs, newest first | +| `show` | `v` | print one job in full, including its log path | +| `rm` | `r` | forget a finished job; refuses a running one | + +`jobs` is the plural alias, as in `job|jobs` elsewhere. A job comes from +`audio transcribe --detach`, `audio summarize --detach`, or the MCP server. + +`--job ` is an internal, hidden flag: it tells a detached child process, +or an MCP-spawned one, which job to report progress into. It is not something +to pass by hand. + ## template ``` @@ -180,11 +208,51 @@ ailoud doctor [--fix] [--yes] [--model ] [--llm ] [--llm-model ] [--llm ] [--llm-model ] +ailoud setup [--yes] [--model ] [--force] [--llm ] [--llm-model ] ``` `--llm` is one of `local`, `claude-cli`, `claude-api`, `openai`, `skip`. +With no `--model`, `setup` installs `large-v3-turbo-q5_0` (574 MB). The names +it offers are `tiny`, `base`, `small`, `large-v3-turbo-q5_0` and `large-v3`; +`medium` and the f16 `large-v3-turbo` are no longer offered but still install +when named, and an installed one is left alone. Which to pick, with the +measurements, is in +[Transcription model](configuration.md#transcription-model). + +`setup --model ` switches the transcription model even when the +configured one is already healthy -- naming a different model is enough, +`--force` is not required. (`doctor --fix --model ` does not: it only +names what a genuinely missing model downloads as, same as before.) The old +model file is never deleted; `setup` prints its path so you can remove it by +hand. With no `--model` at all, `--force` reinstalls whatever is already +configured -- it never replaces it with the default. If the configured +file matches no catalogue name (e.g. a whisper.cpp build of your own), it is +left alone instead, with a note saying so; pass `--model ` to move to a +catalogue model. + +`--force` reinstalls everything ailoud needs, even when every check already +passes: ffmpeg, whisper.cpp, every whisper model. Use it to replace a +corrupted file `doctor` cannot see is broken. If you use a local summariser +(`--llm local`), it also reinstalls llama.cpp and re-downloads its 2.1 GB +model; a hosted summariser (Claude, OpenAI) is untouched either way. + +## self completions + +``` +ailoud self completions install [--shell ] [-y] +ailoud self completions uninstall [--shell ] +ailoud self completions update +ailoud self completions print +``` + +Shells: `bash`, `zsh`, `fish`. Fish autoloads its own completions directory, +so `install` and `uninstall` never touch a fish startup file; bash and zsh +both get a marker block added to (or removed from) `~/.bashrc` / +`~/.zshrc`. `setup --yes` installs none of these; accept its prompt, pass +`--completions` to get them without asking, or `--no-completions` to skip +the prompt and decline. + ## Exit codes | Code | Means | diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 846a02f..054e54b 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -12,11 +12,18 @@ ## A full config file ```yaml +resources: + maxCpuPercent: 90 + gpu: true + +audio: + denoise: off + stt: provider: whisper-cpp whisperCpp: binary: whisper-cli - model: ~/.local/share/ailoud/models/ggml-small.bin + model: ~/.local/share/ailoud/models/ggml-large-v3-turbo-q5_0.bin vadBinary: whisper-vad-speech-segments vadModel: ~/.local/share/ailoud/models/ggml-silero-v5.1.2.bin diarization: @@ -34,6 +41,104 @@ llm: contextTokens: 200000 ``` +| Key | Default | Means | +| ------------------------- | ------- | ------------------------------------------------------------------------------------------------- | +| `resources.maxCpuPercent` | `90` | Share of the machine's fast cores an engine may use, 1 to 100. | +| `resources.gpu` | `true` | Use the GPU where a binary supports it. | +| `audio.denoise` | `off` | `on` cleans the audio before transcription, `auto` cleans only what measures as noisy. See below. | +| `stt.diarization.threads` | `null` | Follow `maxCpuPercent`. A number overrides it. | +| `llm.llamaCpp.threads` | `null` | Follow `maxCpuPercent`. A number overrides it. | + +### Acceleration + +`ailoud doctor` reports which backends each engine loaded: + +``` +GPU build (BLAS, MTL, CPU): transcription is already fast, and threads mainly affect speaker diarization. Raise resources.maxCpuPercent only if diarization is slow. +ok cpu 10 logical, 8 performance -> 7 threads, 6 for segmentation and diarization, at 90% +ok whisper backends BLAS, MTL, CPU +n/a neural engine not available: whisper.cpp reaches the Neural Engine only when built with CoreML support and given a converted model, which the packaged build is not +``` + +whisper.cpp and llama.cpp use Metal or CUDA automatically when their build +supports it, so there is no flag to turn that on. + +What actually changes the speed, measured on 40 seconds of audio with the +`small` model: + +| Threads | GPU build | CPU-only build | +| ------- | --------- | -------------- | +| 1 | 2.6 s | 77.0 s | +| 4 | 2.1 s | 21.0 s | +| 8 | 1.9 s | 19.6 s | + +- A GPU build is about ten times faster, and needs no flag. +- On a GPU build the thread count barely matters, so one thread is fine and + leaves the CPU free. +- Without a GPU, threads are worth about four times, and nearly all of that + by four threads. +- Speech segmentation and speaker diarization always run on the CPU, and both + get a lower share than the other engines because both were measured to slow + down past it -- the segmenter by 39 percent at 7 threads against 6, the + diarizer by 25 percent. + +Apple's Neural Engine would move the encoder off the GPU onto the Neural +Engine, freeing the GPU and cutting encoder time on long files. It needs +whisper.cpp built with `WHISPER_COREML=1` and a model converted to CoreML, +which the packaged builds do not include. See +[whisper.cpp's CoreML instructions](https://github.com/ggml-org/whisper.cpp#core-ml-support) +to build it yourself, then point `stt.whisperCpp.binary` at the result. + +### Transcription model + +`setup` installs `large-v3-turbo-q5_0` (574 MB). Measured on Russian speech, +where the models differ most: + +| Model | Read speech | Conversation | At 10 dB noise | rtf, GPU | +| ----------- | ----------- | ------------ | -------------- | -------- | +| `small` | 7.5% | 32.0% | 12.6% | 0.042 | +| the default | 2.1% | 23.6% | 3.5% | 0.070 | + +Word error rate, then seconds of compute per second of audio. + +- On a GPU the default costs about 1.7x `small`'s decode time: eight minutes + instead of five for a two-hour recording. +- **Without a GPU, how much it costs depends on the machine, and the spread is + wide.** On an Apple Silicon laptop the two are level (0.449 against 0.451 + seconds per second of audio at eight threads), because quantised weights + halve the memory traffic and bandwidth is what limits CPU decoding there. On + a four-core x86 CI runner the same comparison came out about five times + slower for the default, where the extra compute of 32 layers against 12 + dominates instead. Measure your own machine before assuming either figure: + `ailoud doctor` reports what your build loaded, and `--model small` is one + flag away if the default is too slow for you. +- Bigger is not better. `medium` and the f16 build of `large-v3-turbo` were + both dropped from what `setup` offers, because each is beaten by something + smaller. Both still install if you name one: `setup --model medium`. +- `large-v3` is offered as the deliberate maximum. It is measurably better + than the default only on hard audio (about 2 points), was 2 points worse on + far-field meeting audio, and costs 3.1 GB and roughly twice the decode + time. +- English is a poor guide to this choice: every model from `small` up scores + within about a point on clean English narration. + +### Denoising + +`audio.denoise` is `off`, and the measurements say to leave it there. Across +six corpora, eight models and noise from clean down to 0 dB signal-to-noise, +denoising never improved a transcript and several times made one worse -- by +up to 24 points of word error rate on `base`, and by 4.6 points on `large-v3`. +On far-field meeting audio, the one condition where `auto` switches itself on, +it changed the error rate by nothing at all for the default model. + +whisper is already robust to steady background noise; the filter chain takes +speech with it. On a small model it can drop whole passages and still return a +fluent, correctly punctuated sentence, so nothing in the output says a third of +it is missing. + +`--denoise on` is still there for a recording you have listened to and know +needs it. + ## Language model Pick one provider. The others are ignored. @@ -110,7 +215,7 @@ Keys are read from the environment only. They are never written to `config.yaml` and never logged. A variable that is set but empty counts as unset. -## Choosing a model +## Choosing a language model `setup` asks the provider which models your key can use: @@ -125,9 +230,10 @@ ailoud setup --llm claude-api --llm-model claude-opus-5 --yes ``` !!! warning "Context size is not adjusted for you" -No provider reports a model's context window, so switching to a -small-context model needs `contextTokens` set by hand. The symptom is a -context error from the API on a long transcript. + + No provider reports a model's context window, so switching to a + small-context model needs `contextTokens` set by hand. The symptom is a + context error from the API on a long transcript. ## When doctor is unhappy @@ -158,6 +264,11 @@ Three states, not two: Exit codes: `0` ok, `1` failure, `2` usage, `3` environment. +A corrupted file passes its check -- it still exists -- so `doctor` cannot see +the problem. `ailoud setup --force` reinstalls everything regardless of what +the checks say, ffmpeg through every model, for exactly that case -- see the +[CLI reference](cli.md#setup) for what it costs with a local summariser. + ## Concurrency `setup` and `doctor --fix` take a lock on the data directory, so two runs diff --git a/docs/usage/recordings.md b/docs/usage/recordings.md index 70d2460..472d342 100644 --- a/docs/usage/recordings.md +++ b/docs/usage/recordings.md @@ -1,5 +1,35 @@ # Recordings +## Flags + +Where a flag means different things to different verbs, it gets a row each. + +| Flag | Verb | Does | +| ------------------------ | --------------------- | -------------------------------------------------------------------------------------------------- | +| `--tag ` | import | tag the imported recordings; repeatable | +| `--tag ` | transcribe | group these recordings under a tag; repeatable | +| `--tag ` | annotate | group this recording under a tag; repeatable | +| `--tag ` | ls | only recordings carrying this tag; repeatable | +| `--title ` | import, annotate | the recording's title | +| `--notes ` | import, annotate | free-form context about the recording | +| `--lang ` | transcribe | spoken language, several comma-separated, or `auto`. Naming two or more turns on multilingual mode | +| `--multilingual` | transcribe | segment by speech and language, transcribing each run separately | +| `--model ` | transcribe | override the configured model | +| `--diarize` | transcribe | attribute segments to speakers | +| `--speakers ` | transcribe | known number of speakers, to help the diarizer | +| `--speakers` | show | list who spoke, instead of the transcript -- takes no value | +| `--speaker ` | annotate | a real name for one diarizer label; repeatable | +| `--speaker ` | show | only this speaker, by label or by the name you gave them | +| `--transcript ` | show | a specific transcript instead of the newest; a prefix will do | +| `--format ` | show | `text`, `json`, `srt`, `vtt` (default `text`) | +| `--json` | ls | print one JSON array of rows instead of a table | +| `--force` | transcribe | re-transcribe recordings that already have a transcript | +| `--force` | rm | delete without asking | +| `--max-cpu ` | transcribe, summarize | share of this machine to use, 1 to 100 | +| `--no-gpu` | transcribe | do not use the GPU, even where a binary supports it | +| `--denoise ` | transcribe | `auto`, `on` or `off` | +| `--detach` | transcribe | start the work in the background and print its job id | + ## Import ``` @@ -13,8 +43,9 @@ subdirectories. The file you point at is never moved or changed. AILoud keeps its own copy. !!! tip "Always pass `--tag`" -Tags are how you find a recording later by context. The easiest moment to -add one is now, while you know what the file is. See [Tags](#tags). + + Tags are how you find a recording later by context. The easiest moment to + add one is now, while you know what the file is. See [Tags](#tags). ## Transcribe @@ -65,6 +96,45 @@ ailoud audio annotate ID001 --speaker speaker_00=Ann --speaker speaker_01=Ben Names survive `--force`, so re-transcribing does not lose them. +### Run in the background + +`--detach` starts the work and prints a job id instead of waiting: + +``` +ailoud audio transcribe 01M1YDT42V1RMRB80EXHK4R5EQ --lang ru,en --detach +``` + +``` +ok started job 01M1YDT6XENN575E031PE32AKS -- progress in /private/tmp/ailoud-docs-demo/.ailoud/jobs/01M1YDT6XENN575E031PE32AKS.json +``` + +``` +ailoud job ls +``` + +``` +01M1YDT6XENN575E031PE32AKS transcribe running 56% detecting +``` + +``` +ailoud job show 01M1YDT6XENN575E031PE32AKS +``` + +``` +Job 01M1YDT6XENN575E031PE32AKS -- transcribe, done +Progress: 100% (transcribing) +Recordings: 1/1 +Started: 2026-09-07T17:14:20.462Z +Finished: 2026-09-07T17:14:39.096Z +Declared: unknown speakers, languages ru, en +Result: {"transcribed":[{"recordingId":"01M1YDT42V1RMRB80EXHK4R5EQ","transcriptId":"01M1YDTS3NX10EKBGHVPMSR2Z8","language":"en","segments":12}]} +Log: /private/tmp/ailoud-docs-demo/.ailoud/jobs/01M1YDT6XENN575E031PE32AKS.log +``` + +`audio summarize --detach` works the same way. `job rm ` forgets a +finished job; it refuses one still running. Letters: `job l`, `job v`, +`job r`; `jobs` is the plural. + ## Read ``` @@ -86,8 +156,6 @@ ailoud audio ls --tag release ailoud audio ls --tag release --tag backend # both, not either ``` -Several tags narrow. A recording must carry all of them. - ## Titles and notes ``` diff --git a/docs/usage/templates.md b/docs/usage/templates.md index f45498b..36173aa 100644 --- a/docs/usage/templates.md +++ b/docs/usage/templates.md @@ -89,5 +89,6 @@ A template needs a `context` sentence and at least two headings. One heading is a title, not a shape. !!! tip -Before writing a template, try `--context` on an existing one. It adjusts -a summary without adding a shape you then have to maintain. + + Before writing a template, try `--context` on an existing one. It adjusts + a summary without adding a shape you then have to maintain. diff --git a/docs/usage/updating.md b/docs/usage/updating.md new file mode 100644 index 0000000..1ec5e7f --- /dev/null +++ b/docs/usage/updating.md @@ -0,0 +1,43 @@ +# Updating ailoud + +```shell +ailoud self update +``` + +`self update` asks the registry itself, so nothing has to be run before it. It +installs the newer version if there is one, then refreshes the rules block in +every registered project and the shell completions wherever they are already +installed. + +```shell +ailoud self check # only look, change nothing +ailoud self check --json # the same answer, for a script +ailoud self sync # refresh the rules and completions without updating +``` + +`self check` is for looking without installing; `self update` does not need +it. + +## What counts as newer + +| You are on | Can move to | +| --------------- | ----------------------------------------------------- | +| a final release | any newer final release | +| `X.Y.Z-dev.N` | a newer `dev` of the same `X.Y.Z`, or any newer final | +| `X.Y.Z-rc.N` | a newer `rc` of the same `X.Y.Z`, or any newer final | + +A deprecated version is never offered. + +## Turning off the passive notice + +Other commands mention an update at most once a day. `self check` and +`self update` themselves are unaffected by either switch. + +```shell +export AILOUD_NO_UPDATE_CHECK=1 +``` + +```yaml +update: + check: false +``` diff --git a/e2e/src/cli.ts b/e2e/src/cli.ts index 9541c1a..15c8d72 100644 --- a/e2e/src/cli.ts +++ b/e2e/src/cli.ts @@ -2,12 +2,12 @@ // built binary through here, and nowhere else. `makeSandbox()` is the only // export that can start the binary, and the process it spawns always gets // a throwaway HOME, XDG_CONFIG_HOME, and XDG_DATA_HOME. There is no -// exported raw spawn and no way to pass a caller-supplied env that could -// override those three variables -- forgetting any one of them would let a -// spec write into the developer's real library, which is exactly the -// failure this file exists to prevent. +// exported raw spawn, and `run`'s own `env` option cannot override those +// three variables no matter what a caller passes -- forgetting any one of +// them would let a spec write into the developer's real library, which is +// exactly the failure this file exists to prevent. import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -23,6 +23,26 @@ export interface CliResult { readonly stderr: string; } +export interface RunOptions { + /** + * Extra variables for this one call, layered UNDER the sandbox's own + * HOME/XDG_CONFIG_HOME/XDG_DATA_HOME -- those three always win, even if a + * caller names them here. What this is for: threading `AILOUD_PACKUMENTS` + * (a JSON fixture path, never a server -- see `packumentFixture` in + * `self-update.spec.ts`) or a stub-tool directory prepended to `PATH`. + */ + readonly env?: Record; + /** + * Overrides the child's cwd for this one call, still somewhere under the + * sandbox. Defaults to `projectDir`. Load-bearing for the specs that + * register more than one project directory in the same sandbox: `self + * sync` sweeps `projects.json`, whose entries are real paths, so testing a + * pruned or a concurrently-registered project needs more than one such + * path to exist. + */ + readonly cwd?: string; +} + export interface Sandbox { /** The sandboxed $HOME. Nothing outside it should ever be touched. */ readonly home: string; @@ -40,7 +60,7 @@ export interface Sandbox { */ readonly projectDir: string; /** Runs the built binary with this sandbox's environment. The only way a spec invokes it. */ - run(args: readonly string[]): Promise; + run(args: readonly string[], options?: RunOptions): Promise; /** Writes config.yaml inside the sandbox, creating its parent directory. */ writeConfig(yaml: string): Promise; /** Removes the sandbox directory. Call once the test is done with it. */ @@ -73,8 +93,33 @@ function runProcess( }); } +/** + * The parent's own environment, minus the variables that have bitten this + * project's tests before: every `GITHUB_` variable (CI sets several that + * change script behaviour -- see `scripts/testing/harness.mjs`) and + * `AILOUD_NO_UPDATE_CHECK`, so a spec's outcome never depends on whatever + * happened to be exported in the shell -- or the CI job -- that ran it. + */ +function scrubbedEnv(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('GITHUB_') && key !== 'AILOUD_NO_UPDATE_CHECK', + ), + ); +} + export async function makeSandbox(): Promise { - const home = await mkdtemp(join(tmpdir(), 'ailoud-e2e-')); + // Resolved through realpath immediately: on macOS, os.tmpdir() answers + // under /var/folders, but the OS reports a spawned child's own cwd already + // canonicalised to /private/var/folders -- the same directory, a + // different string. Left unresolved here, `sandbox.projectDir` would not + // byte-for-byte equal a path the CLI itself prints or records (a project + // registry entry, `self sync`'s own report), while a bare `toContain()` + // check could still pass by accident: the unresolved form is a plain + // substring of the canonical one. Resolving once, here, is what makes + // every path this sandbox hands out compare equal to what the binary + // actually sees. + const home = await realpath(await mkdtemp(join(tmpdir(), 'ailoud-e2e-'))); const configHome = join(home, 'config'); const dataHome = join(home, 'data'); const configFile = join(configHome, 'ailoud', 'config.yaml'); @@ -82,23 +127,27 @@ export async function makeSandbox(): Promise { const projectDir = join(home, 'project'); await mkdir(projectDir, { recursive: true }); - // Every one of these three variables matters: dropping any single one - // falls back to the real $HOME-derived default in apps/cli/src/config.ts - // and points the binary at the developer's actual library. - const env: NodeJS.ProcessEnv = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: configHome, - XDG_DATA_HOME: dataHome, - }; + const base = scrubbedEnv(); return { home, configFile, dataDir, projectDir, - run(args) { - return runProcess(args, env, projectDir); + run(args, options) { + // Every one of these three matters: dropping any single one falls back + // to the real $HOME-derived default in apps/cli/src/config.ts and + // points the binary at the developer's actual library. Applied LAST, + // after any caller-supplied `options.env`, so nothing above can shadow + // them. + const env: NodeJS.ProcessEnv = { + ...base, + ...options?.env, + HOME: home, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: dataHome, + }; + return runProcess(args, env, options?.cwd ?? projectDir); }, async writeConfig(yaml) { await mkdir(dirname(configFile), { recursive: true }); diff --git a/e2e/src/models.test.ts b/e2e/src/models.test.ts new file mode 100644 index 0000000..6ed10ac --- /dev/null +++ b/e2e/src/models.test.ts @@ -0,0 +1,107 @@ +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { installedVadModel, installedWhisperModel, modelsDir } from './models'; + +/** + * These run under vitest, not jest: the helper is picked by the e2e specs at + * module load, so a mistake here would surface as every transcribing spec + * pointing at the wrong file -- and those specs only run on a provisioned + * machine, where the failure would be a CI-only surprise. + */ +describe('installedWhisperModel', () => { + let home: string; + let models: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'ailoud-models-test-')); + models = modelsDir(home); + await mkdir(models, { recursive: true }); + }); + + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('finds the installed transcription model whatever it is called', async () => { + await writeFile(join(models, 'ggml-large-v3-turbo-q5_0.bin'), 'x'); + + expect(installedWhisperModel(home)).toBe(join(models, 'ggml-large-v3-turbo-q5_0.bin')); + }); + + it('ignores the vad model, which lives in the same directory', async () => { + // The vad file is made the NEWEST on purpose. Written in either order it + // would lose to the whisper model on the mtime tiebreak, so the case + // would pass with the exclusion deleted -- proving nothing. Made newest, + // only the exclusion can produce the right answer. + const whisper = join(models, 'ggml-small.bin'); + const vad = join(models, 'ggml-silero-v5.1.2.bin'); + await writeFile(whisper, 'x'); + await writeFile(vad, 'x'); + const future = new Date(Date.now() + 60_000); + await utimes(vad, future, future); + + expect(installedWhisperModel(home)).toBe(whisper); + }); + + it('ignores files that are not whisper models', async () => { + // Same reasoning as above: the decoys are the newest files present. + const whisper = join(models, 'ggml-base.bin'); + await writeFile(whisper, 'x'); + const future = new Date(Date.now() + 60_000); + for (const decoy of ['qwen2.5-3b-instruct-q4_k_m.gguf', 'sherpa-pyannote-3-0.onnx']) { + await writeFile(join(models, decoy), 'x'); + await utimes(join(models, decoy), future, future); + } + + expect(installedWhisperModel(home)).toBe(whisper); + }); + + it('prefers the most recently written model when several are installed', async () => { + const older = join(models, 'ggml-small.bin'); + const newer = join(models, 'ggml-large-v3-turbo-q5_0.bin'); + await writeFile(older, 'x'); + await writeFile(newer, 'x'); + const past = new Date(Date.now() - 60_000); + await utimes(older, past, past); + + expect(installedWhisperModel(home)).toBe(newer); + }); + + it('returns a path rather than throwing when the directory is empty', async () => { + // The suite's rule is that a spec needing whisper fails loudly naming the + // missing file. Throwing here would instead break collection of the whole + // spec file, since the constant is evaluated at module load. + expect(installedWhisperModel(home)).toBe(join(models, 'ggml-no-model-installed.bin')); + }); + + it('returns a path rather than throwing when the directory does not exist', async () => { + await rm(models, { recursive: true, force: true }); + + expect(installedWhisperModel(home)).toBe(join(models, 'ggml-no-model-installed.bin')); + }); + + it('names the vad model the provisioner installs', () => { + expect(installedVadModel(home)).toBe(join(models, 'ggml-silero-v5.1.2.bin')); + }); + + it('prefers an explicitly chosen model over whatever is installed', async () => { + await writeFile(join(models, 'ggml-large-v3-turbo-q5_0.bin'), 'x'); + + expect(installedWhisperModel(home, { AILOUD_E2E_MODEL: '/pinned/ggml-small.bin' })).toBe( + '/pinned/ggml-small.bin', + ); + }); + + it('falls back to discovery when the choice is unset or empty', async () => { + // Empty counts as unset, the same rule the app applies to its own + // environment variables -- an exported-but-blank value in a workflow + // must not pin the specs to a path of "". + const installed = join(models, 'ggml-base.bin'); + await writeFile(installed, 'x'); + + expect(installedWhisperModel(home, {})).toBe(installed); + expect(installedWhisperModel(home, { AILOUD_E2E_MODEL: '' })).toBe(installed); + }); +}); diff --git a/e2e/src/models.ts b/e2e/src/models.ts new file mode 100644 index 0000000..06655db --- /dev/null +++ b/e2e/src/models.ts @@ -0,0 +1,65 @@ +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Where `ailoud setup` puts model files, under the real unsandboxed data dir. */ +export function modelsDir(home: string): string { + return join(home, '.local', 'share', 'ailoud', 'models'); +} + +/** + * The transcription model an `ailoud setup` on this machine installed. + * + * Discovered rather than named. These specs used to hard-code + * `ggml-small.bin`, which was the catalogue's default at the time; when the + * default became `large-v3-turbo-q5_0` every transcribing spec would have + * started pointing at a file `setup` no longer downloads. The specs do not + * care WHICH real model they get -- they assert a transcript against a + * reference with a loose error-rate ceiling that any genuine model clears -- + * so asking the filesystem what is there is both more robust and closer to + * what they mean. + * + * The VAD model lives in the same directory and is not a transcription + * model, so it is excluded by name. Where several models are installed the + * most recently written one wins, which is the one the last `setup` + * provisioned. + * + * Returns a path even when nothing is installed, deliberately: this suite's + * standing rule is that a spec needing whisper fails loudly naming what is + * missing rather than skipping, and a plausible path is what produces that + * message. + */ +export function installedWhisperModel(home: string, env: NodeJS.ProcessEnv = process.env): string { + // An explicit choice wins over discovery. CI sets this to a small model: + // the specs here test the pipeline, not model quality, and the shipped + // default is several times slower on a four-core runner -- it took the + // provisioned suite from five minutes to nineteen. That the real default + // downloads and installs is proven by the `setup` step itself, which is a + // different question from whether `transcribe` works. + const chosen = env['AILOUD_E2E_MODEL']; + if (chosen !== undefined && chosen !== '') return chosen; + const dir = modelsDir(home); + let entries: readonly string[]; + try { + entries = readdirSync(dir); + } catch { + return join(dir, 'ggml-no-model-installed.bin'); + } + const candidates = entries + .filter((name) => name.startsWith('ggml-') && name.endsWith('.bin') && !name.includes('silero')) + .map((name) => join(dir, name)) + .sort((a, b) => mtime(b) - mtime(a)); + return candidates[0] ?? join(dir, 'ggml-no-model-installed.bin'); +} + +/** The VAD model, needed only by the `--multilingual` specs. */ +export function installedVadModel(home: string): string { + return join(modelsDir(home), 'ggml-silero-v5.1.2.bin'); +} + +function mtime(path: string): number { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } +} diff --git a/e2e/src/setupNoTools.cjs b/e2e/src/setupNoTools.cjs new file mode 100644 index 0000000..7844aa1 --- /dev/null +++ b/e2e/src/setupNoTools.cjs @@ -0,0 +1,10 @@ +// Jest setupFiles entry for the `no-tools` project (see jest.config.cjs). +// +// resources.spec.ts is the one spec file that belongs to both projects at +// once: its stub-only cases run everywhere, but its real-audio cases must +// never run here, on a machine CI never provisions with ffmpeg or +// whisper-cli. Jest assigns a whole FILE to a project via testMatch; it has +// no equivalent for one describe block inside a file shared by two projects. +// This env flag is that missing granularity -- resources.spec.ts reads it +// and skips the real-audio describe block whenever it is not "true". +process.env.AILOUD_E2E_TOOLS = 'false'; diff --git a/e2e/src/setupTools.cjs b/e2e/src/setupTools.cjs new file mode 100644 index 0000000..b5951be --- /dev/null +++ b/e2e/src/setupTools.cjs @@ -0,0 +1,5 @@ +// Jest setupFiles entry for the `tools` project (see jest.config.cjs and +// setupNoTools.cjs, its counterpart). Marks this run as one where real +// ffmpeg, whisper-cli and a model are expected, so resources.spec.ts's +// real-audio describe block actually executes here instead of skipping. +process.env.AILOUD_E2E_TOOLS = 'true'; diff --git a/e2e/tests/completions.spec.ts b/e2e/tests/completions.spec.ts new file mode 100644 index 0000000..fbbf248 --- /dev/null +++ b/e2e/tests/completions.spec.ts @@ -0,0 +1,139 @@ +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { makeSandbox } from '../src/cli'; +import type { Sandbox } from '../src/cli'; + +/** + * End-to-end coverage of `ailoud self completions install|uninstall|update|print`. + * + * Driven through the built binary in a sandboxed HOME, XDG_CONFIG_HOME and + * XDG_DATA_HOME. This is the one part of the feature whose whole job is + * editing a real shell startup file: a unit test can prove the generator and + * the marker-block writer are each right in isolation, but only running the + * binary against a real `.bashrc` proves it finds the right file, leaves a + * hand-written line in it alone, and never touches a file it was not told to. + */ + +// Mirrors the pair exported as START/END from apps/cli/src/completions/install.ts. +// Not imported: no e2e spec imports product source (see cli.ts's own doc +// comment) -- the built binary's actual output is what is under test here. +const START = '# >>> ailoud completions >>>'; +const END = '# <<< ailoud completions <<<'; + +jest.setTimeout(120_000); + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +const read = (path: string): Promise => readFile(path, 'utf8'); + +/** + * Where each shell's files land, mirroring apps/cli/src/completions/shells.ts: + * bash and zsh write into the user data directory, fish into + * XDG_CONFIG_HOME/fish/completions (fish autoloads that directory itself, so + * it alone gets no rc file). + */ +function completionPaths(sandbox: Sandbox) { + // sandbox.configFile is "/ailoud/config.yaml" (see config.ts). + const configHome = dirname(dirname(sandbox.configFile)); + return { + bashScript: join(sandbox.dataDir, 'completions', 'ailoud.bash'), + zshScript: join(sandbox.dataDir, 'completions', '_ailoud'), + fishScript: join(configHome, 'fish', 'completions', 'ailoud.fish'), + bashrc: join(sandbox.home, '.bashrc'), + zshrc: join(sandbox.home, '.zshrc'), + }; +} + +describe('ailoud self completions', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('print bash writes a script to stdout and installs nothing', async () => { + const result = await sandbox.run(['self', 'completions', 'print', 'bash']); + expect(result.code).toBe(0); + expect(result.stdout).toContain('-F _ailoud ailoud'); + + // "print" only renders; it must never reach for the install/rc writers. + const p = completionPaths(sandbox); + for (const path of [p.bashScript, p.zshScript, p.fishScript, p.bashrc, p.zshrc]) { + expect(await exists(path)).toBe(false); + } + }); + + it('names a real command in the generated bash script', async () => { + // Catches a generator that emits an empty (or wrongly-scoped) case table: + // every structural unit test can pass while the script itself completes + // to nothing, because none of them render the live command tree end to + // end the way the binary does here. + const result = await sandbox.run(['self', 'completions', 'print', 'bash']); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/\b(transcribe|summarize)\b/); + }); + + it('install --shell bash writes the script and the block, keeping a hand-written line', async () => { + const p = completionPaths(sandbox); + await writeFile(p.bashrc, 'export EDITOR=vim\n', 'utf8'); + + const result = await sandbox.run(['self', 'completions', 'install', '--shell', 'bash']); + expect(result.code).toBe(0); + + expect(await read(p.bashScript)).toContain('-F _ailoud ailoud'); + + const rc = await read(p.bashrc); + expect(rc).toContain('export EDITOR=vim'); + expect(rc).toContain(START); + expect(rc).toContain(END); + expect(rc).toContain(p.bashScript); + }); + + it('reports no change on a second install', async () => { + await sandbox.run(['self', 'completions', 'install', '--shell', 'bash']); + const before = await read(completionPaths(sandbox).bashrc); + + const second = await sandbox.run(['self', 'completions', 'install', '--shell', 'bash']); + expect(second.code).toBe(0); + expect(second.stdout).toContain('unchanged'); + expect(second.stdout).not.toMatch(/created|updated/); + expect(await read(completionPaths(sandbox).bashrc)).toBe(before); + }); + + it('uninstall --shell bash removes both, keeping the hand-written line', async () => { + const p = completionPaths(sandbox); + await writeFile(p.bashrc, 'export EDITOR=vim\n', 'utf8'); + await sandbox.run(['self', 'completions', 'install', '--shell', 'bash']); + + const result = await sandbox.run(['self', 'completions', 'uninstall', '--shell', 'bash']); + expect(result.code).toBe(0); + + expect(await exists(p.bashScript)).toBe(false); + const rc = await read(p.bashrc); + expect(rc).toContain('export EDITOR=vim'); + expect(rc).not.toContain(START); + expect(rc).not.toContain(END); + }); + + it('install --shell fish writes only the fish file and edits no startup file', async () => { + const p = completionPaths(sandbox); + const result = await sandbox.run(['self', 'completions', 'install', '--shell', 'fish']); + expect(result.code).toBe(0); + + expect(await exists(p.fishScript)).toBe(true); + for (const path of [p.bashScript, p.zshScript, p.bashrc, p.zshrc]) { + expect(await exists(path)).toBe(false); + } + }); +}); diff --git a/e2e/tests/jobs.spec.ts b/e2e/tests/jobs.spec.ts new file mode 100644 index 0000000..21068f5 --- /dev/null +++ b/e2e/tests/jobs.spec.ts @@ -0,0 +1,301 @@ +// End-to-end test for background jobs with progress tracking. +// Exercises the full detach flow: running transcriptions in the background, +// polling progress, and managing jobs via the CLI. +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { Sandbox } from '../src/cli'; +import { installedWhisperModel } from '../src/models'; +import { makeSandbox } from '../src/cli'; + +const REPO_ROOT = join(__dirname, '..', '..'); +const FIXTURES_DIR = join(REPO_ROOT, 'fixtures'); + +const LONG_WAV = join(FIXTURES_DIR, 'three-speakers-en.wav'); + +const REAL_HOME = process.env['HOME'] ?? ''; +const WHISPER_MODEL = installedWhisperModel(REAL_HOME); + +/** Parse the job id from transcribe --detach output. */ +function parseDetachId(output: string): string { + // Output is: "started job -- progress in " + const match = /started job (\S+)/.exec(output); + if (match === null) { + throw new Error(`invalid --detach output: ${JSON.stringify(output)}`); + } + return match[1]!; +} + +/** Parse job ls output to extract job ids. */ +function parseJobLsOutput(output: string): string[] { + const lines = output.trim().split('\n'); + if (lines.length === 0) return []; + // Each line is: "id kind state percent stage" + return lines.map((line) => { + const parts = line.trim().split(/\s+/); + return parts[0] ?? ''; + }); +} + +interface JobState { + readonly id: string; + readonly kind: string; + readonly state: 'running' | 'done' | 'failed'; + readonly percent: number; + readonly stage: string; + readonly pid: number; + readonly startedAt: string; + readonly finishedAt: string | null; + readonly recordings: { readonly total: number; readonly done: number }; + readonly declared: { + readonly speakers: number | 'unknown'; + readonly languages: readonly string[]; + } | null; + readonly log: string; + readonly result: unknown; + readonly error: string | null; +} + +/** Read the job state file from the sandbox's data directory. */ +async function readJobState(sandbox: Sandbox, jobId: string): Promise { + const jobsDir = join(sandbox.dataDir, 'jobs'); + const statePath = join(jobsDir, `${jobId}.json`); + try { + const content = await readFile(statePath, 'utf8'); + return JSON.parse(content) as JobState; + } catch { + return null; + } +} + +/** Sleep for a given number of milliseconds. */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('ailoud background jobs', () => { + let sandbox: Sandbox; + const createdJobIds: string[] = []; + + beforeEach(async () => { + sandbox = await makeSandbox(); + createdJobIds.length = 0; + }); + + /** + * Whether a process with the given pid is still alive. Uses the same + * signal-0 check as `isRunning` in apps/cli/src/exclusiveLock.ts: ESRCH + * means no such process (dead), EPERM means process exists under another + * user (alive). + * + * A deliberate copy rather than an import of that exported function: this + * suite runs under e2e/tsconfig.json (CommonJS, its own "include": ["src", + * "tests"]), and apps/cli is an ESM package under NodeNext with relative + * imports that end in `.js`. Reaching across that boundary into another + * workspace package's `src` would need either a build-output import (this + * suite drives the CLI as a subprocess precisely to test the built + * artifact, not its internals) or a second tsconfig project reference for + * five lines of logic. `exclusiveLock.ts`'s own header already explains why + * a second copy of this check is a real risk -- it was wrong twice before + * being extracted -- so this copy is kept intentionally small and pinned to + * that file by name in this comment, not reinvented. + */ + function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } + } + + afterEach(async () => { + // Detached jobs survive their launcher by design, so they must be killed + // by pid. The pid is stored in the state file, which will be deleted by + // sandbox.cleanup(), so extract it before cleanup. + for (const jobId of createdJobIds) { + let state: JobState | null; + try { + state = await readJobState(sandbox, jobId); + } catch { + // State file already gone or unreadable; skip cleanup for this job + continue; + } + + if (state !== null && (state.state === 'running' || state.state === 'failed')) { + // Only kill if the job has not reached a terminal state, or if it + // failed but the process might still be alive. Only skip if state + // is 'done', which means the child exited cleanly. + if (isProcessAlive(state.pid)) { + try { + // Negative pid: signals the whole process GROUP, not just the + // recorded pid. `spawnDetachedJob` starts the child with + // `detached: true`, which calls setsid() and makes it the leader + // of a new group containing whisper. A plain + // `process.kill(state.pid, ...)` would reach only the ailoud + // child -- whisper is a grandchild in the same group and would + // survive, along with the six-hour timeout that was supposed to + // bound it (that timer lives in the ailoud child that just died, + // not in whisper itself). + process.kill(-state.pid, 'SIGTERM'); + } catch { + // Process already gone; this is fine + } + } + } + } + + // Now it is safe to remove the sandbox: all child processes have been + // signalled to stop. + await sandbox.cleanup(); + }); + + it('transcribes in the background and reports progress to completion', async () => { + await sandbox.writeConfig(`stt:\n whisperCpp:\n model: ${WHISPER_MODEL}\n`); + + // Import the fixture + const importResult = await sandbox.run(['import', LONG_WAV]); + expect(importResult.code).toBe(0); + // import output includes [text] decorations and "imported" status + const rawOutput = importResult.stdout.trim(); + const importMatch = rawOutput.match(/(\S+)\s+imported/); + expect(importMatch).not.toBeNull(); + const recordingId = importMatch![1]!; + + // Start a background transcription + const detachResult = await sandbox.run(['transcribe', recordingId, '--lang', 'en', '--detach']); + expect(detachResult.code).toBe(0); + const jobId = parseDetachId(detachResult.stdout); + expect(jobId.length).toBeGreaterThan(0); + createdJobIds.push(jobId); + + // Poll the job state until it finishes, collecting progress percentages + let state = await readJobState(sandbox, jobId); + expect(state).not.toBeNull(); + expect(state!.state).toBe('running'); + + // Give the child process time to start and produce initial progress + await delay(1000); + + const seen: number[] = []; + const deadline = Date.now() + 10 * 60_000; // 10 minute timeout for transcription + // The child process reports progress, which JobReporter throttles to every + // 2000ms when writing the state file. Poll frequently to maximize chances + // of catching intermediate values between 0% and 100%. + while (Date.now() < deadline) { + state = await readJobState(sandbox, jobId); + if (state === null) break; + seen.push(state.percent); + if (state.state !== 'running') break; + await delay(300); // Poll every 300ms + } + // Final state should be done + expect(state).not.toBeNull(); + expect(state!.state).toBe('done'); + expect(state!.percent).toBe(100); + + // Progress must have moved from 0 to 100, not jumped at the end. Both + // assertions below would pass for a bar that only ever reported [0, 100] + // -- max > 0 is satisfied by the final 100 alone -- so what actually + // proves "did not jump at the end" is a value strictly between the two. + expect(Math.max(...seen)).toBeGreaterThan(0); + expect(seen.some((percent) => percent > 0 && percent < 100)).toBe(true); + + // Progress must never go backwards (monotonic increasing) + const sorted = [...seen].sort((a, b) => a - b); + expect(sorted).toEqual(seen); + + // Verify the transcript was created and contains expected text + const showResult = await sandbox.run(['show', recordingId, '--format', 'text']); + expect(showResult.code).toBe(0); + const shown = showResult.stdout.toLowerCase(); + expect(shown).toContain('engine room'); + }); + + it('lists the finished job and then forgets it', async () => { + await sandbox.writeConfig(`stt:\n whisperCpp:\n model: ${WHISPER_MODEL}\n`); + + // Import and start a background transcription + const importResult = await sandbox.run(['import', LONG_WAV]); + expect(importResult.code).toBe(0); + const rawOutput = importResult.stdout.trim(); + const importMatch = rawOutput.match(/(\S+)\s+imported/); + expect(importMatch).not.toBeNull(); + const recordingId = importMatch![1]!; + + const detachResult = await sandbox.run(['transcribe', recordingId, '--lang', 'en', '--detach']); + expect(detachResult.code).toBe(0); + const jobId = parseDetachId(detachResult.stdout); + createdJobIds.push(jobId); + + // Wait for the job to finish + let state = await readJobState(sandbox, jobId); + const deadline = Date.now() + 10 * 60_000; + while (state !== null && state.state === 'running' && Date.now() < deadline) { + await delay(2000); + state = await readJobState(sandbox, jobId); + } + expect(state!.state).toBe('done'); + + // List jobs -- the finished one should appear + const lsResult = await sandbox.run(['job', 'ls']); + expect(lsResult.code).toBe(0); + const listedIds = parseJobLsOutput(lsResult.stdout); + expect(listedIds).toContain(jobId); + + // Remove the job + const rmResult = await sandbox.run(['job', 'rm', jobId]); + expect(rmResult.code).toBe(0); + expect(rmResult.stdout).toContain('removed'); + + // Show should now report UNKNOWN + const showResult = await sandbox.run(['job', 'show', jobId]); + expect(showResult.code).not.toBe(0); + expect(showResult.stderr).toContain('UNKNOWN'); + }); + + it('refuses a second job while one is running', async () => { + await sandbox.writeConfig(`stt:\n whisperCpp:\n model: ${WHISPER_MODEL}\n`); + + // Import the fixture + const importResult = await sandbox.run(['import', LONG_WAV]); + expect(importResult.code).toBe(0); + const rawOutput = importResult.stdout.trim(); + const importMatch = rawOutput.match(/(\S+)\s+imported/); + expect(importMatch).not.toBeNull(); + const recordingId = importMatch![1]!; + + // Start the first background transcription + const firstDetachResult = await sandbox.run([ + 'transcribe', + recordingId, + '--lang', + 'en', + '--detach', + ]); + expect(firstDetachResult.code).toBe(0); + const firstJobId = parseDetachId(firstDetachResult.stdout); + createdJobIds.push(firstJobId); + + // Confirm the first job is actually running + let state = await readJobState(sandbox, firstJobId); + let attempts = 0; + while ((state === null || state.state !== 'running') && attempts < 20) { + await delay(500); + state = await readJobState(sandbox, firstJobId); + attempts += 1; + } + expect(state!.state).toBe('running'); + + // Attempt to start a second transcription -- should be refused + const secondDetachResult = await sandbox.run([ + 'transcribe', + recordingId, + '--lang', + 'en', + '--detach', + ]); + expect(secondDetachResult.code).not.toBe(0); + // The error should name the holder (first job id) + expect(secondDetachResult.stderr.toLowerCase()).toMatch(/job|running|lock|holder|refused/i); + }); +}); diff --git a/e2e/tests/mcp-install.spec.ts b/e2e/tests/mcp-install.spec.ts index 864893a..a8200d7 100644 --- a/e2e/tests/mcp-install.spec.ts +++ b/e2e/tests/mcp-install.spec.ts @@ -53,8 +53,11 @@ describe('ailoud mcp install', () => { expect(config.mcpServers.ailoud.command).toBe('ailoud'); expect(config.mcpServers.ailoud.args).toEqual(['mcp']); - // The rules block, which is what makes an agent use the tools well. - const rules = await read(join(sandbox.projectDir, 'CLAUDE.md')); + // The rules block, which is what makes an agent use the tools well. A + // fresh project has no CLAUDE.md yet, so the block lands in the + // preferred candidate, .claude/CLAUDE.md, rather than creating one at + // the project root. + const rules = await read(join(sandbox.projectDir, '.claude', 'CLAUDE.md')); expect(rules).toContain(START); expect(rules).toContain(END); expect(rules).toContain('search_transcripts'); @@ -84,7 +87,10 @@ describe('ailoud mcp install', () => { }); it('appends to a rules file that already exists instead of creating a second one', async () => { - const claudeMd = join(sandbox.projectDir, 'CLAUDE.md'); + // .claude/CLAUDE.md is the preferred candidate, so a project that already + // has one gets it appended to rather than a competing file at the root. + const claudeMd = join(sandbox.projectDir, '.claude', 'CLAUDE.md'); + await mkdir(join(sandbox.projectDir, '.claude'), { recursive: true }); await writeFile(claudeMd, '# My Project\n\nMy own rules.\n', 'utf8'); await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); @@ -92,12 +98,13 @@ describe('ailoud mcp install', () => { const rules = await read(claudeMd); expect(rules).toContain('My own rules.'); expect(rules).toContain(START); - // Not a competing file under .claude/. - expect(await exists(join(sandbox.projectDir, '.claude', 'CLAUDE.md'))).toBe(false); + // Not a competing file at the root. + expect(await exists(join(sandbox.projectDir, 'CLAUDE.md'))).toBe(false); }); it("leaves another tool's block in the rules file alone", async () => { - const claudeMd = join(sandbox.projectDir, 'CLAUDE.md'); + const claudeMd = join(sandbox.projectDir, '.claude', 'CLAUDE.md'); + await mkdir(join(sandbox.projectDir, '.claude'), { recursive: true }); await writeFile( claudeMd, '# P\n\n\nCodeGraph rules\n\n', @@ -112,7 +119,7 @@ describe('ailoud mcp install', () => { it('is idempotent: a second install changes no bytes', async () => { await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); const firstConfig = await read(join(sandbox.projectDir, '.mcp.json')); - const firstRules = await read(join(sandbox.projectDir, 'CLAUDE.md')); + const firstRules = await read(join(sandbox.projectDir, '.claude', 'CLAUDE.md')); const second = await sandbox.run([ 'mcp', @@ -125,7 +132,7 @@ describe('ailoud mcp install', () => { expect(second.code).toBe(0); expect(second.stdout).toContain('unchanged'); expect(await read(join(sandbox.projectDir, '.mcp.json'))).toBe(firstConfig); - expect(await read(join(sandbox.projectDir, 'CLAUDE.md'))).toBe(firstRules); + expect(await read(join(sandbox.projectDir, '.claude', 'CLAUDE.md'))).toBe(firstRules); }); it('writes each agent its own format', async () => { @@ -244,6 +251,123 @@ describe('ailoud mcp install', () => { // Nothing is installed in a sandbox with no agent directories at all. expect(result.stdout).toMatch(/No agents selected|nothing was configured/); }); + + it('creates .claude/CLAUDE.md rather than appending to the project CLAUDE.md', async () => { + await writeFile(join(sandbox.projectDir, 'CLAUDE.md'), '# Project rules\n'); + const result = await sandbox.run([ + 'mcp', + 'install', + '--target', + 'claude', + '--location', + 'local', + ]); + expect(result.code).toBe(0); + + expect(await read(join(sandbox.projectDir, 'CLAUDE.md'))).toBe('# Project rules\n'); + expect(await read(join(sandbox.projectDir, '.claude', 'CLAUDE.md'))).toContain(START); + }); + + it('updates a block already in the project CLAUDE.md instead of moving it', async () => { + await writeFile( + join(sandbox.projectDir, 'CLAUDE.md'), + `# Project rules\n\n${START}\nstale\n${END}\n`, + ); + await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); + + const rules = await read(join(sandbox.projectDir, 'CLAUDE.md')); + expect(rules).toContain('# Project rules'); + expect(rules).toContain('search_transcripts'); + expect(rules).not.toContain('stale'); + expect(await exists(join(sandbox.projectDir, '.claude', 'CLAUDE.md'))).toBe(false); + }); + + it('keeps both rules files current when both already carry the block', async () => { + await writeFile( + join(sandbox.projectDir, 'CLAUDE.md'), + `# Root rules\n\n${START}\nstale\n${END}\n`, + ); + await mkdir(join(sandbox.projectDir, '.claude'), { recursive: true }); + await writeFile( + join(sandbox.projectDir, '.claude', 'CLAUDE.md'), + `# Nested rules\n\n${START}\nstale\n${END}\n`, + ); + await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); + + for (const path of [ + join(sandbox.projectDir, 'CLAUDE.md'), + join(sandbox.projectDir, '.claude', 'CLAUDE.md'), + ]) { + const rules = await read(path); + expect(rules).toContain('search_transcripts'); + expect(rules).not.toContain('stale'); + } + expect(await read(join(sandbox.projectDir, 'CLAUDE.md'))).toContain('# Root rules'); + expect(await read(join(sandbox.projectDir, '.claude', 'CLAUDE.md'))).toContain( + '# Nested rules', + ); + }); + + it('pre-approves running ailoud only when asked, and takes it back on uninstall', async () => { + const settings = join(sandbox.projectDir, '.claude', 'settings.json'); + await mkdir(join(sandbox.projectDir, '.claude'), { recursive: true }); + await writeFile(settings, JSON.stringify({ hooks: { UserPromptSubmit: [] } }, null, 2)); + + await sandbox.run([ + 'mcp', + 'install', + '--target', + 'claude', + '--location', + 'local', + '--allow-shell', + ]); + expect(JSON.parse(await read(settings)).permissions.allow).toEqual(['Bash(ailoud:*)']); + + await sandbox.run(['mcp', 'uninstall', '--target', 'claude', '--location', 'local']); + const after = JSON.parse(await read(settings)); + expect(after.permissions).toBeUndefined(); + // The user's own settings in the same file survive both halves. + expect(after.hooks).toEqual({ UserPromptSubmit: [] }); + }); + + it('says which directory a Copilot grant covers, even when nothing was asked', async () => { + // `-y --allow-shell` skips the pre-prompt listing entirely, and the + // outcome row names a machine-wide file for an entry keyed by one + // directory. Without this line the user is told they approved more than + // they did. + const result = await sandbox.run([ + 'mcp', + 'install', + '--target', + 'copilot', + '-y', + '--allow-shell', + ]); + expect(result.code).toBe(0); + // Nothing else in this run could print the project directory: the install + // is global and every file it touches lives under HOME. + expect(result.stdout).toContain(sandbox.projectDir); + expect(result.stdout).toContain('not machine-wide'); + const config = JSON.parse( + await read(join(sandbox.home, '.copilot', 'permissions-config.json')), + ); + expect(Object.keys(config.locations)).toEqual([sandbox.projectDir]); + }); + + it('grants nothing for -y on its own', async () => { + const result = await sandbox.run([ + 'mcp', + 'install', + '--target', + 'claude', + '--location', + 'local', + '-y', + ]); + expect(result.code).toBe(0); + expect(await exists(join(sandbox.projectDir, '.claude', 'settings.json'))).toBe(false); + }); }); describe('ailoud mcp uninstall', () => { @@ -258,8 +382,12 @@ describe('ailoud mcp uninstall', () => { }); it('deletes a config file it created and restores a rules file byte for byte', async () => { - const claudeMd = join(sandbox.projectDir, 'CLAUDE.md'); + // .claude/CLAUDE.md, the preferred candidate: a plain root CLAUDE.md with + // no block of ours is never touched at all, so it cannot exercise the + // edit-then-restore path this test is for. + const claudeMd = join(sandbox.projectDir, '.claude', 'CLAUDE.md'); const original = '# My Project\n\nMy own rules.\n'; + await mkdir(join(sandbox.projectDir, '.claude'), { recursive: true }); await writeFile(claudeMd, original, 'utf8'); await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); @@ -332,9 +460,15 @@ describe('ailoud mcp uninstall', () => { expect(await exists(join(sandbox.home, '.claude.json'))).toBe(false); }); - it('cleans every agent format it wrote', async () => { + // Both answers to the allow-list question, because the leftover only + // appeared for the "yes" one: the allow-list entries kept the settings + // files looking like they still held something of the user's. + it.each([ + ['without the allow-list', [] as string[]], + ['with the allow-list too', ['--allow-shell']], + ])('cleans every agent format it wrote, %s', async (_name, extra) => { const targets = 'claude,codex,opencode,gemini'; - await sandbox.run(['mcp', 'install', '--target', targets, '--location', 'local']); + await sandbox.run(['mcp', 'install', '--target', targets, '--location', 'local', ...extra]); await sandbox.run(['mcp', 'uninstall', '--target', targets, '--location', 'local']); for (const path of [ @@ -342,10 +476,14 @@ describe('ailoud mcp uninstall', () => { 'opencode.jsonc', '.codex/config.toml', '.gemini/settings.json', + // The allow-lists: opencode's and Gemini's are the two files above, + // Claude Code's is its own, and Codex keeps one policy for the machine. + '.claude/settings.json', ]) { expect(await exists(join(sandbox.projectDir, path))).toBe(false); } - for (const path of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) { + expect(await exists(join(sandbox.home, '.codex', 'policy.yaml'))).toBe(false); + for (const path of ['AGENTS.md', '.claude/CLAUDE.md', 'GEMINI.md']) { // Created solely for the block, so removed with it. expect(await exists(join(sandbox.projectDir, path))).toBe(false); } @@ -364,7 +502,9 @@ describe('ailoud mcp update', () => { }); it('refreshes a stale rules block in place', async () => { - const claudeMd = join(sandbox.projectDir, 'CLAUDE.md'); + // A fresh install puts the block in .claude/CLAUDE.md, the preferred + // candidate -- there is no root CLAUDE.md here for it to land in. + const claudeMd = join(sandbox.projectDir, '.claude', 'CLAUDE.md'); await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); // Simulate an older AILoud having written a different block. diff --git a/e2e/tests/pipeline.spec.ts b/e2e/tests/pipeline.spec.ts index 4d94312..71e42e4 100644 --- a/e2e/tests/pipeline.spec.ts +++ b/e2e/tests/pipeline.spec.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Sandbox } from '../src/cli'; import { makeSandbox } from '../src/cli'; +import { installedVadModel, installedWhisperModel } from '../src/models'; import { wordErrorRate } from '../src/wer'; const REPO_ROOT = join(__dirname, '..', '..'); @@ -40,15 +41,17 @@ const GIT_STATUS_TIMEOUT_MS = 10_000; * fixture model -- whisper.cpp models are hundreds of megabytes -- so this * points at the same manual-install location the maintainer's own * `~/.config/ailoud/config.yaml` uses: a `models/` directory under the real, - * unsandboxed XDG data dir. `process.env.HOME` here is deliberately the - * *outer* test-runner process's HOME, not a sandbox's -- `makeSandbox()` - * only overrides the child process's environment, never this file's own. + * unsandboxed XDG data dir. WHICH model is not named here; see + * `installedWhisperModel`, which asks that directory what `setup` left. + * `process.env.HOME` here is deliberately the *outer* test-runner process's + * HOME, not a sandbox's -- `makeSandbox()` only overrides the child + * process's environment, never this file's own. * `VAD_MODEL` is only needed by the `--multilingual` specs; the others * configure `WHISPER_MODEL` alone. */ const REAL_HOME = process.env['HOME'] ?? ''; -const WHISPER_MODEL = join(REAL_HOME, '.local', 'share', 'ailoud', 'models', 'ggml-small.bin'); -const VAD_MODEL = join(REAL_HOME, '.local', 'share', 'ailoud', 'models', 'ggml-silero-v5.1.2.bin'); +const WHISPER_MODEL = installedWhisperModel(REAL_HOME); +const VAD_MODEL = installedVadModel(REAL_HOME); /** A distinctive word from the English clause of fixtures/mixed-short.txt. */ const MIXED_EN_WORD = 'tomorrow'; @@ -274,6 +277,13 @@ describe('ailoud end-to-end', () => { // intact and leaves the phonetic drifters out rather than baking today's // misspellings in as expectations -- that would turn a guard into a // snapshot of a model version. + // Two of these are stems, and the transcript has its hyphens removed + // before matching, because the comment above means what it says and the + // list had quietly become a snapshot of `small`'s spellings. The current + // default writes "тайм-аут" -- the dictionary form -- and "деплай", one + // vowel off. Neither is the word being replaced by an unrelated one, + // which is the only thing this guard is for. A stem still fails on a + // replacement: nothing unrelated to deployment starts with "депл". const LOANWORDS = [ 'дедлайн', 'релиз', @@ -281,7 +291,7 @@ describe('ailoud end-to-end', () => { 'реквест', 'митинг', 'юзер', - 'деплой', + 'депл', 'лог', 'таймаут', 'рефакторинг', @@ -297,7 +307,7 @@ describe('ailoud end-to-end', () => { const shown = await sandbox.run(['show', id, '--format', 'json']); expect(shown.code).toBe(0); - const transcript = transcriptTextFromShowJson(shown.stdout).toLowerCase(); + const transcript = transcriptTextFromShowJson(shown.stdout).toLowerCase().replace(/-/g, ''); const missing = LOANWORDS.filter((word) => !transcript.includes(word)); if (missing.length > 0) { diff --git a/e2e/tests/resources.spec.ts b/e2e/tests/resources.spec.ts new file mode 100644 index 0000000..f2ea58d --- /dev/null +++ b/e2e/tests/resources.spec.ts @@ -0,0 +1,750 @@ +// End-to-end coverage of the resource-budget and denoise flags: +// `--max-cpu`, `--no-gpu` and `--denoise`, and what `doctor` reports about +// the machine they act on. +// +// This file is split across BOTH jest projects (see jest.config.cjs): the +// `describe` block below drives the built binary entirely through stub +// binaries on PATH (see writeStub) and never spawns a real engine, so it +// belongs to `no-tools`, which runs on every push. A second `describe` +// block, appended by a later change, drives real ffmpeg/whisper-cli/a model +// and belongs to `tools` alone -- guarded by AILOUD_E2E_TOOLS, which +// setupNoTools.cjs/setupTools.cjs set per project, since Jest's testMatch +// can only assign a whole FILE to a project, not one describe block within +// it shared by two. +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { availableParallelism } from 'node:os'; +import { delimiter, join } from 'node:path'; +import type { Sandbox } from '../src/cli'; +import { installedWhisperModel } from '../src/models'; +import { makeSandbox } from '../src/cli'; +import { wordErrorRate } from '../src/wer'; + +const REPO_ROOT = join(__dirname, '..', '..'); +const FIXTURES_DIR = join(REPO_ROOT, 'fixtures'); +const EN_WAV = join(FIXTURES_DIR, 'en-short.wav'); + +/** + * A deliberate COPY of two pure functions this spec needs to compute its own + * expectations the same way the code does: `resourceBudget` from + * packages/core/src/resources/budget.ts, and the darwin branch of + * `cpuTopology` from packages/providers/src/system/cpuTopology.ts. + * + * Not an import, for the same reason jobs.spec.ts's `isProcessAlive` is a + * copy rather than one (see that file's own comment): `@ailoud/core` and + * `@ailoud/providers` are ESM-only workspace packages that are not linked + * into the repository root's node_modules -- only apps/cli and + * packages/providers declare them as dependencies -- and this suite runs + * under e2e/tsconfig.json's CommonJS. Reaching them would need either a + * build-output import (this suite drives the CLI as a subprocess precisely + * to test the built artifact, not its internals) or a second tsconfig + * project reference for a few lines of pure arithmetic. Kept intentionally + * small and pinned to both files by name in this comment, not reinvented. + */ +interface LocalCpuTopology { + readonly logical: number; + readonly performance: number | null; +} + +interface LocalResourceBudget { + readonly threads: number; + readonly cappedThreads: number; +} + +const CAPPED_MAX_THREADS = 6; +const DEFAULT_MAX_CPU_PERCENT = 90; + +function localCpuTopology(): LocalCpuTopology { + const logical = Math.max(1, Math.round(availableParallelism())); + if (process.platform !== 'darwin') return { logical, performance: null }; + try { + const stdout = execFileSync('sysctl', ['-n', 'hw.perflevel0.logicalcpu'], { + encoding: 'utf8', + timeout: 5_000, + }); + const trimmed = stdout.trim(); + const value = Number(trimmed); + if (trimmed === '' || !Number.isInteger(value) || value < 1 || value > logical) { + return { logical, performance: null }; + } + return { logical, performance: value }; + } catch { + return { logical, performance: null }; + } +} + +function clamp(value: number, low: number, high: number): number { + if (value < low) return low; + if (value > high) return high; + return value; +} + +function localResourceBudget( + topology: LocalCpuTopology, + options: { readonly maxCpuPercent?: number } = {}, +): LocalResourceBudget { + const requested = options.maxCpuPercent; + const percent = + requested !== undefined && Number.isFinite(requested) && requested >= 1 && requested <= 100 + ? requested + : DEFAULT_MAX_CPU_PERCENT; + const base = Math.max(1, Math.round(topology.performance ?? topology.logical)); + const threads = clamp(Math.round((base * percent) / 100), 1, base); + const cappedThreads = Math.min(threads, CAPPED_MAX_THREADS); + return { threads, cappedThreads }; +} + +/** Parses an `import` output line: " imported|already present ". */ +function parseImportLine(line: string): { id: string; status: string; path: string } { + const match = /^(\S+)\s+(imported|already present)\s+(.+)$/.exec(line); + if (match === null) throw new Error(`unexpected import output: ${JSON.stringify(line)}`); + return { id: match[1]!, status: match[2]!, path: match[3]! }; +} + +/** Parse the job id from `transcribe --detach` output: "started job -- ...". */ +function parseDetachId(output: string): string { + const match = /started job (\S+)/.exec(output); + if (match === null) throw new Error(`invalid --detach output: ${JSON.stringify(output)}`); + return match[1]!; +} + +interface JobState { + readonly state: 'running' | 'done' | 'failed'; + /** + * Read only to say how far a job got when it fails to finish in time. A + * deliberately partial view of the real state document, as the rest of + * this spec's copies are: the suite drives the built binary as a black box + * and does not import the app's types. + */ + readonly percent?: number; + readonly stage?: string; +} + +/** Reads the job state file from the sandbox's data directory, or null before it exists. */ +async function readJobState(sandbox: Sandbox, jobId: string): Promise { + const statePath = join(sandbox.dataDir, 'jobs', `${jobId}.json`); + try { + return JSON.parse(await readFile(statePath, 'utf8')) as JobState; + } catch { + return null; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Polls the job state file until it reaches a terminal state, or the deadline passes. */ +/** + * Waits for a detached job to stop running. + * + * Five minutes, not one. A minute was enough while the default model was + * `small`; it stopped being enough the day the default became + * `large-v3-turbo-q5_0`, and this is where CI said so. On a CPU-only runner + * with four cores, jest running suites in parallel means several real + * whisper processes share those cores, and a 574 MB model has to be loaded + * before any of them decodes anything. The jest timeout above this is ten + * minutes, so five leaves room to fail as a timeout rather than as a + * killed worker. + * + * The message on giving up names how far the job got. The bare "expected + * done, received running" this used to produce says nothing about whether + * the job was progressing slowly or wedged, which is the first thing anyone + * reading a CI log needs to know. + */ +async function waitForTerminal(sandbox: Sandbox, jobId: string): Promise { + const budgetMs = 300_000; + const deadline = Date.now() + budgetMs; + let state = await readJobState(sandbox, jobId); + while ((state === null || state.state === 'running') && Date.now() < deadline) { + await delay(200); + state = await readJobState(sandbox, jobId); + } + if (state === null) throw new Error(`job ${jobId} never wrote a state file`); + if (state.state === 'running') { + throw new Error( + `job ${jobId} was still running after ${budgetMs / 1000}s: ` + + `${state.percent ?? '?'}% at stage "${state.stage ?? '?'}"`, + ); + } + return state; +} + +/** + * A fake engine binary that appends its argv to a file and produces the + * minimum output the adapter parses. This is how argv is asserted without a + * real whisper, which is what keeps this spec in the no-tools project. + */ +async function writeStub( + dir: string, + name: string, + body: string, +): Promise<{ readonly path: string; argv(): Promise }> { + const path = join(dir, name); + const log = join(dir, `${name}.argv`); + await writeFile(path, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\n${body}\n`, { + mode: 0o755, + }); + return { + path, + async argv() { + const raw = await readFile(log, 'utf8').catch(() => ''); + return raw + .split('\n') + .filter((line) => line !== '') + .map((line) => line.split(' ')); + }, + }; +} + +// Every body below answers `--help` first: `doctor` probes every configured +// binary with it (checkBinary, probeBackends), and without this branch the +// stub would fall into its normal logic and either write a bogus output file +// or produce output the doctor-facing parsers do not expect. + +/** + * Derived from packages/providers/src/stt/whisperCpp.ts's parsers: writes + * `.json` in the shape `parseWhisperJson` expects (one + * non-blank segment), answers `-dl` the way `parseDetectedLanguage` expects, + * and answers `--help` the way `parseBackends` expects (a `load_backend: + * loaded backend` line), reporting `CPU` so `doctor` prints the + * CPU-only setup note rather than inventing a GPU backend this stub does + * not have. + */ +const WHISPER_BODY = ` +case " $* " in + *" --help "*) + echo "load_backend: loaded CPU backend from stub" 1>&2 + exit 0 + ;; +esac +case " $* " in + *" -dl "*) + echo "auto-detected language: en" 1>&2 + exit 0 + ;; +esac +outbase="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-of" ]; then outbase="$arg"; fi + prev="$arg" +done +if [ -n "$outbase" ]; then + cat > "$outbase.json" <<'EOF_STUB_JSON' +{"result":{"language":"en"},"transcription":[{"offsets":{"from":0,"to":1000},"text":" stub transcript"}]} +EOF_STUB_JSON +fi +echo "whisper_print_progress_callback: progress = 100%" 1>&2 +exit 0 +`; + +/** Derived from whisperVad.ts's SEGMENT_LINE: one "Speech segment" line, as its own doc comment specifies. */ +const VAD_BODY = ` +case " $* " in + *" --help "*) + exit 0 + ;; +esac +echo "Detected 1 speech segments:" +echo "Speech segment 0: start = 0.00, end = 100.00" +exit 0 +`; + +/** Derived from sherpaDiarizer.ts's TURN_LINE: one turn line, as its own doc comment specifies. */ +const DIARIZER_BODY = ` +case " $* " in + *" --help "*) + exit 0 + ;; +esac +echo "1.583 -- 3.406 speaker_00" +exit 0 +`; + +/** Writes whatever the last argument names, so `toWav16kMono` has an output file to hand the next stage. */ +const FFMPEG_BODY = ` +case " $* " in + *" -version "*) + echo "ffmpeg version stub" + exit 0 + ;; +esac +last="" +for arg in "$@"; do last="$arg"; done +: > "$last" +exit 0 +`; + +/** A duration ffprobe.probe() can parse, so import never fails on a stubbed recording. */ +const FFPROBE_BODY = ` +case " $* " in + *" -version "*) + echo "ffprobe version stub" + exit 0 + ;; +esac +echo '{"format":{"duration":"5.0"}}' +exit 0 +`; + +interface Engines { + /** Prepend this to PATH so ffmpeg/ffprobe (bare names, never configurable) resolve to the stubs. */ + readonly pathEntry: string; + readonly whisper: { argv(): Promise }; + readonly vad: { argv(): Promise }; + readonly diarizer: { argv(): Promise }; +} + +/** + * Points every engine binary and model this feature touches at a stub or a + * placeholder, so `transcribe --diarize --multilingual` and `doctor` all run + * to completion without ffmpeg, whisper.cpp, or sherpa-onnx installed. + * Model files are never read for content -- only checked for existence by + * `doctor` -- so an empty placeholder is enough. + */ +async function setupEngines(sandbox: Sandbox): Promise { + const stubDir = join(sandbox.home, 'stub-bin'); + await mkdir(stubDir, { recursive: true }); + const whisper = await writeStub(stubDir, 'whisper-cli', WHISPER_BODY); + const vad = await writeStub(stubDir, 'whisper-vad-speech-segments', VAD_BODY); + const diarizer = await writeStub( + stubDir, + 'sherpa-onnx-offline-speaker-diarization', + DIARIZER_BODY, + ); + await writeStub(stubDir, 'ffmpeg', FFMPEG_BODY); + await writeStub(stubDir, 'ffprobe', FFPROBE_BODY); + + const modelsDir = join(sandbox.home, 'models'); + await mkdir(modelsDir, { recursive: true }); + const whisperModel = join(modelsDir, 'whisper.bin'); + const vadModel = join(modelsDir, 'vad.bin'); + const segmentationModel = join(modelsDir, 'segmentation.onnx'); + const embeddingModel = join(modelsDir, 'embedding.onnx'); + await Promise.all( + [whisperModel, vadModel, segmentationModel, embeddingModel].map((path) => + writeFile(path, 'stub model, never read for content\n', 'utf8'), + ), + ); + + await sandbox.writeConfig( + `stt:\n` + + ` whisperCpp:\n` + + ` model: ${whisperModel}\n` + + ` vadModel: ${vadModel}\n` + + ` diarization:\n` + + ` segmentationModel: ${segmentationModel}\n` + + ` embeddingModel: ${embeddingModel}\n`, + ); + + return { + pathEntry: `${stubDir}${delimiter}${process.env['PATH'] ?? ''}`, + whisper, + vad, + diarizer, + }; +} + +describe('resource flags', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('passes the budget it computed to whisper and a smaller one to the diarizer', async () => { + // The expectation is computed the same way the code computes it, so this + // holds on any machine rather than on one core count. + const budget = localResourceBudget(localCpuTopology(), { maxCpuPercent: 100 }); + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + + const imported = await sandbox.run(['import', EN_WAV], { env }); + const id = parseImportLine(imported.stdout.trim()).id; + + const transcribed = await sandbox.run( + ['transcribe', id, '--diarize', '--max-cpu', '100', '--denoise', 'off'], + { env }, + ); + expect(transcribed.code).toBe(0); + + const whisperArgv = (await engines.whisper.argv()).flat(); + expect(whisperArgv).toEqual(expect.arrayContaining(['-t', String(budget.threads)])); + + const diarizerArgv = (await engines.diarizer.argv())[0]!.join(' '); + expect(diarizerArgv).toContain(`--segmentation.num-threads=${budget.cappedThreads}`); + expect(diarizerArgv).toContain(`--embedding.num-threads=${budget.cappedThreads}`); + }); + + it('never gives a capped engine more than the measured optimum', async () => { + // The regression this field exists to prevent: both capped engines were + // measured fastest at 6 threads and much slower above it, so the cap is an + // absolute ceiling rather than a fraction of the machine. Pure + // computation -- no engine spawned. + // + // Stated as the invariant rather than as "capped is smaller than the + // ceiling": on a machine with 6 or fewer usable cores the two are equal + // and nothing is being capped, which is correct. An earlier version of + // this test guarded on `threads > 2`, calibrated for a `base - 2` rule + // that no longer exists, and would have failed on any 4-core runner. + const budget = localResourceBudget(localCpuTopology(), { maxCpuPercent: 100 }); + expect(budget.cappedThreads).toBeLessThanOrEqual(CAPPED_MAX_THREADS); + expect(budget.cappedThreads).toBeLessThanOrEqual(budget.threads); + expect(budget.cappedThreads).toBeGreaterThanOrEqual(1); + if (budget.threads > CAPPED_MAX_THREADS) { + expect(budget.cappedThreads).toBe(CAPPED_MAX_THREADS); + } + }); + + it('gives whisper fewer threads at a lower percent', async () => { + const high = localResourceBudget(localCpuTopology(), { maxCpuPercent: 100 }).threads; + const low = localResourceBudget(localCpuTopology(), { maxCpuPercent: 10 }).threads; + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + + const imported = await sandbox.run(['import', EN_WAV], { env }); + const id = parseImportLine(imported.stdout.trim()).id; + + const first = await sandbox.run(['transcribe', id, '--max-cpu', '100', '--denoise', 'off'], { + env, + }); + expect(first.code).toBe(0); + const second = await sandbox.run( + ['transcribe', id, '--force', '--max-cpu', '10', '--denoise', 'off'], + { env }, + ); + expect(second.code).toBe(0); + + const calls = await engines.whisper.argv(); + const threadsOf = (argv: string[]): string => argv[argv.indexOf('-t') + 1]!; + expect(threadsOf(calls[0]!)).toBe(String(high)); + expect(threadsOf(calls[1]!)).toBe(String(low)); + expect(low).toBeLessThan(high); + expect(low).toBeGreaterThanOrEqual(1); + }); + + it('omits -ng by default and includes it with --no-gpu', async () => { + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + + const imported = await sandbox.run(['import', EN_WAV], { env }); + const id = parseImportLine(imported.stdout.trim()).id; + + const withGpu = await sandbox.run(['transcribe', id, '--denoise', 'off'], { env }); + expect(withGpu.code).toBe(0); + const withoutGpu = await sandbox.run( + ['transcribe', id, '--force', '--no-gpu', '--denoise', 'off'], + { env }, + ); + expect(withoutGpu.code).toBe(0); + + const calls = await engines.whisper.argv(); + expect(calls[0]).not.toContain('-ng'); + expect(calls[1]).toContain('-ng'); + }); + + it('never passes -ng to the vad binary', async () => { + // That binary has no such flag: passing it would make every multilingual + // run fail at segmentation. + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + + const imported = await sandbox.run(['import', EN_WAV], { env }); + const id = parseImportLine(imported.stdout.trim()).id; + + const transcribed = await sandbox.run( + ['transcribe', id, '--multilingual', '--no-gpu', '--denoise', 'off'], + { env }, + ); + expect(transcribed.code).toBe(0); + + const vadArgv = (await engines.vad.argv()).flat().join(' '); + expect(vadArgv).not.toContain('-ng'); + }); + + it('never passes a flag that was measured and rejected', async () => { + // sherpa's provider (measured 20% slower), llama's -ngl (unmeasurable + // here) and whisper's -p (trades accuracy for speed). If a later change + // reintroduces one without a measurement, this fails. + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + + const imported = await sandbox.run(['import', EN_WAV], { env }); + const id = parseImportLine(imported.stdout.trim()).id; + + const transcribed = await sandbox.run( + ['transcribe', id, '--diarize', '--multilingual', '--no-gpu', '--denoise', 'off'], + { env }, + ); + expect(transcribed.code).toBe(0); + + const everything = [ + ...(await engines.whisper.argv()).flat(), + ...(await engines.vad.argv()).flat(), + ...(await engines.diarizer.argv()).flat(), + ].join(' '); + expect(everything).not.toContain('provider'); + expect(everything).not.toContain('-ngl'); + expect(everything).not.toMatch(/(^| )-p( |$)/); + }); + + it.each(['0', '101', 'abc'])('refuses --max-cpu %s and spawns nothing', async (value) => { + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + const { code, stderr } = await sandbox.run(['transcribe', 'ZZZZZZZZ', '--max-cpu', value], { + env, + }); + expect(code).not.toBe(0); + expect(stderr).toMatch(/1.*100/); + expect(await engines.whisper.argv()).toEqual([]); + }); + + it('refuses an unknown --denoise mode, naming the three', async () => { + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + const { code, stderr } = await sandbox.run( + ['transcribe', 'ZZZZZZZZ', '--denoise', 'sometimes'], + { env }, + ); + expect(code).not.toBe(0); + for (const mode of ['auto', 'on', 'off']) expect(stderr).toContain(mode); + }); + + it('forwards all three flags to a detached child', async () => { + // Asserted from the child's own recorded argv, not from the parent's: + // the parent exits immediately, and a flag lost in between would be + // invisible. + const budget = localResourceBudget(localCpuTopology(), { maxCpuPercent: 50 }); + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + + const imported = await sandbox.run(['import', EN_WAV], { env }); + const id = parseImportLine(imported.stdout.trim()).id; + + const detached = await sandbox.run( + ['transcribe', id, '--detach', '--max-cpu', '50', '--no-gpu', '--denoise', 'off'], + { env }, + ); + expect(detached.code).toBe(0); + const jobId = parseDetachId(detached.stdout); + const state = await waitForTerminal(sandbox, jobId); + expect(state.state).toBe('done'); + + const childArgv = (await engines.whisper.argv())[0]!; + expect(childArgv).toEqual(expect.arrayContaining(['-t', String(budget.threads)])); + expect(childArgv).toContain('-ng'); + }); + + it('prints the acceleration checks and still exits zero', async () => { + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + const { code, stdout } = await sandbox.run(['doctor'], { env }); + expect(stdout).toContain('cpu'); + expect(stdout).toContain('neural engine'); + expect(code).toBe(0); + }); + + it('leads with the setup note, naming one case and not both', async () => { + // An outside agent reading doctor reads the top. The two cases give + // opposite advice, so printing both would be worse than printing neither. + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + const { stdout } = await sandbox.run(['doctor'], { env }); + // Not a bare `stdout.includes('GPU build')`: the CPU-only message's own + // text is "...than on a GPU build...", so that substring is present in + // BOTH cases and cannot tell them apart. Each case's distinguishing + // prefix -- "GPU build (" with the backend list, versus "CPU-only + // build:" -- is what actually never appears in the other. + const first = stdout.split('\n').find((line) => line.trim() !== '') ?? ''; + expect(first).toMatch(/GPU build \(|CPU-only build:/); + expect(stdout.includes('GPU build (') && stdout.includes('CPU-only build:')).toBe(false); + }); + + it('never tells the agent to ask about --max-cpu', async () => { + // The rule this feature ships with: the agent should UNDERSTAND what + // makes ailoud fast, and never spend a turn asking the user to set a + // flag whose default is already right. + const engines = await setupEngines(sandbox); + const env = { PATH: engines.pathEntry }; + const { stdout } = await sandbox.run(['doctor'], { env }); + expect(stdout).not.toContain('--max-cpu'); + }); + + it('writes a rules block that tells the agent not to ask, under its ceiling', async () => { + // --target and --location, and the rules file path, all taken from + // e2e/tests/mcp-install.spec.ts and apps/cli/src/mcp/agents.ts rather + // than from a guessed "--agent claude-code" and a root CLAUDE.md: a + // fresh sandbox has neither yet, so the preferred candidate is + // .claude/CLAUDE.md. + const result = await sandbox.run([ + 'mcp', + 'install', + '--target', + 'claude', + '--location', + 'local', + ]); + expect(result.code).toBe(0); + const rules = await readFile(join(sandbox.projectDir, '.claude', 'CLAUDE.md'), 'utf8'); + expect(rules).toMatch(/not ask about CPU or GPU/i); + expect(rules).not.toContain('--max-cpu'); + }); +}); + +// --------------------------------------------------------------------------- +// Real audio, below. Everything above this line drives the binary through +// stub engines and belongs to both jest projects. Everything from here on +// spawns a real ffmpeg/whisper-cli against a real model and belongs to +// `tools` alone -- guarded by AILOUD_E2E_TOOLS (see the file header and +// jest.config.cjs), which is why the whole describe block below is wrapped +// in a runtime check rather than split into a second file: Jest's testMatch +// assigns a whole file to a project, never one describe block within it. + +const REAL_TOOLS = process.env['AILOUD_E2E_TOOLS'] === 'true'; + +const NOISY_WAV = join(FIXTURES_DIR, 'noisy-short.wav'); + +/** + * A real, working whisper.cpp model this block needs to actually transcribe + * rather than merely check that a path exists. There is no packaged fixture + * model -- whisper.cpp models are hundreds of megabytes -- so this points at + * the same manual-install location the maintainer's own + * `~/.config/ailoud/config.yaml` uses: a `models/` directory under the real, + * unsandboxed XDG data dir. See pipeline.spec.ts's own WHISPER_MODEL comment + * for the full reasoning; kept as a second, local copy here rather than an + * import because these are two independent spec files under Jest, neither of + * which exports anything for the other to import. + */ +const REAL_HOME = process.env['HOME'] ?? ''; +const WHISPER_MODEL = installedWhisperModel(REAL_HOME); + +/** Below this, a transcript is close enough to the reference to prove the right audio reached the right model. */ +const WER_THRESHOLD = 0.2; + +interface ShowJson { + readonly segments: ReadonlyArray<{ readonly text: string }>; +} + +/** + * The transcript's actual words, from `show --format json`'s segments -- + * not `--format text`, which prefixes every line with a timestamp that would + * count as extra reference-mismatched words. See pipeline.spec.ts's own + * transcriptTextFromShowJson for the full reasoning; a second small copy, + * for the same reason WHISPER_MODEL above is one. + */ +function transcriptTextFromShowJson(raw: string): string { + const parsed = JSON.parse(raw) as ShowJson; + return parsed.segments.map((segment) => segment.text).join(' '); +} + +/** The reference transcript for a fixture (fixtures/.txt), trimmed. */ +async function reference(name: string): Promise { + return (await readFile(join(FIXTURES_DIR, `${name}.txt`), 'utf8')).trim(); +} + +/** + * Reads a job's append-only log file: jobsDir/.log, plain text, one line + * per notice (see apps/cli/src/jobs/log.ts and the `onNotice` wiring in + * apps/cli/src/commands/transcribe.ts). Distinct from the job STATE file + * (`readJobState`, above): the denoise decision is written by `onNotice`, + * which reaches only the job log, never the terminal or the state document. + */ +async function readJobLog(sandbox: Sandbox, jobId: string): Promise { + return readFile(join(sandbox.dataDir, 'jobs', `${jobId}.log`), 'utf8'); +} + +/** + * Imports a fixture, transcribes it against the real, configured model, and + * returns the transcript's text. Throws with the CLI's own stderr on a + * non-zero exit, so a genuine defect is reported rather than swallowed into + * a confusing downstream assertion failure. + */ +async function transcribeFixture( + sandbox: Sandbox, + fixturePath: string, + extraArgs: readonly string[], +): Promise { + await sandbox.writeConfig(`stt:\n whisperCpp:\n model: ${WHISPER_MODEL}\n`); + const imported = await sandbox.run(['import', fixturePath]); + const id = parseImportLine(imported.stdout.trim()).id; + const transcribed = await sandbox.run(['transcribe', id, ...extraArgs]); + if (transcribed.code !== 0) { + throw new Error(`transcribe failed (code ${transcribed.code}): ${transcribed.stderr}`); + } + const shown = await sandbox.run(['show', id, '--format', 'json']); + return transcriptTextFromShowJson(shown.stdout); +} + +(REAL_TOOLS ? describe : describe.skip)('resource limits against real audio', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it.each(['10', '100'])('transcribes en-short.wav correctly at --max-cpu %s', async (percent) => { + // The limit changes speed, never output. This is the whole safety + // property: a resource hint is an optimisation, never a precondition. + const text = await transcribeFixture(sandbox, EN_WAV, ['--max-cpu', percent]); + expect(wordErrorRate(await reference('en-short'), text)).toBeLessThan(WER_THRESHOLD); + }); + + it('transcribes the noisy fixture correctly with --denoise on', async () => { + // Asserts the filter chain is HARMLESS. It deliberately does not claim + // the denoising rescued anything: raw noisy-short.wav also transcribes + // correctly (measured: 16.35 dB SNR, below the "noisy" threshold, yet + // still within WER_THRESHOLD both raw and denoised) -- a test claiming a + // rescue here would pass for the wrong reason. + const text = await transcribeFixture(sandbox, NOISY_WAV, ['--denoise', 'on']); + expect(wordErrorRate(await reference('noisy-short'), text)).toBeLessThan(WER_THRESHOLD); + }); + + it('leaves clean audio alone on auto, and says so in the job log', async () => { + // --detach, because the "not denoised" line goes to the job log only: + // a routine decision is not a warning and does not reach the terminal + // (see denoiseMessage/onNotice in + // packages/core/src/pipelines/transcribe.ts and + // apps/cli/src/commands/transcribe.ts). + await sandbox.writeConfig(`stt:\n whisperCpp:\n model: ${WHISPER_MODEL}\n`); + const imported = await sandbox.run(['import', EN_WAV]); + const id = parseImportLine(imported.stdout.trim()).id; + + const detached = await sandbox.run(['transcribe', id, '--denoise', 'auto', '--detach']); + expect(detached.code).toBe(0); + const jobId = parseDetachId(detached.stdout); + const state = await waitForTerminal(sandbox, jobId); + expect(state.state).toBe('done'); + + const log = await readJobLog(sandbox, jobId); + expect(log).toMatch(/not denoised/i); + // The measured number travels with the decision. en-short.wav is 27.1 dB. + expect(log).toMatch(/2[0-9]\.[0-9] dB/); + + const shown = await sandbox.run(['show', id, '--format', 'json']); + const text = transcriptTextFromShowJson(shown.stdout); + expect(wordErrorRate(await reference('en-short'), text)).toBeLessThan(WER_THRESHOLD); + }); + + it('names a real backend for whisper-cli, and names segmentation and diarization on the cpu line', async () => { + await sandbox.writeConfig(`stt:\n whisperCpp:\n model: ${WHISPER_MODEL}\n`); + await sandbox.run(['import', EN_WAV]); // ensures dataDir exists; irrelevant to this check + const { code, stdout } = await sandbox.run(['doctor']); + expect(code).toBe(0); + // On any real ggml build at least one backend loads. + expect(stdout).toMatch(/whisper backends\s+\S/); + // Shape, not the numbers: they differ from machine to machine (see the + // MEASURED comment on accelerationChecks in apps/cli/src/commands/doctor.ts). + expect(stdout).toMatch( + /cpu\s+\d+ logical(?:, \d+ performance)? -> \d+ threads, \d+ for segmentation and diarization, at \d+%/, + ); + }); +}); diff --git a/e2e/tests/self-update.spec.ts b/e2e/tests/self-update.spec.ts new file mode 100644 index 0000000..59a17bc --- /dev/null +++ b/e2e/tests/self-update.spec.ts @@ -0,0 +1,421 @@ +// End-to-end coverage of `ailoud self check|update|sync` and the project +// registry behind `self sync`, driven through the built binary rather than +// in-memory fakes. Tasks 1-12 already proved the logic against fakes; this +// is what proves the wiring around it -- process spawning, file paths, +// `projects.json` -- is real. +// +// No spec here ever reaches a real `npm install -g` or `pnpm add -g`. Run +// from its own checkout, the repository's own binary is never an installed +// `npm-global` or `pnpm-global` copy, so `self update` naturally refuses with +// a hint instead of installing anything -- see the "refuses" specs below, +// which exercise that natural path with no stubbing at all. The one spec +// that needs the OTHER branch, to see `--dry-run`'s plan, reaches it by +// putting a stub `npm` first on PATH that only ever answers `root -g` with +// the repository's own directory. Even that cannot lead to a real install: +// the install command `--dry-run` prints is built from `installCommandFor` +// (packages/providers/src/update/installMethod.ts), anchored to the real +// node binary's own directory, never to anything found on PATH, and +// `--dry-run` returns before any command is ever spawned regardless. +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { makeSandbox } from '../src/cli'; +import type { Sandbox } from '../src/cli'; + +const REPO_ROOT = join(__dirname, '..', '..'); + +/** A quick, local command; a few seconds is already generous. */ +const GIT_STATUS_TIMEOUT_MS = 10_000; + +const START = ''; + +jest.setTimeout(120_000); + +function gitStatus(): string { + return execFileSync('git', ['status', '--porcelain'], { + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: GIT_STATUS_TIMEOUT_MS, + }); +} + +// Captured before any sandbox runs, so the final check below can prove THIS +// suite left the repository exactly as it found it -- not that the tree was +// clean to begin with. A developer with uncommitted work is the common case, +// not an edge case, and a check that fails for that reason is a check that +// gets ignored. +const statusBeforeSuite = gitStatus(); + +const read = (path: string): Promise => readFile(path, 'utf8'); + +async function exists(path: string): Promise { + try { + await readFile(path); + return true; + } catch { + return false; + } +} + +/** Mirrors `ProjectEntry` (apps/cli/src/projects.ts), for reading and + * rewriting `projects.json` directly in a spec. */ +interface RegistryEntry { + readonly path: string; + readonly firstSeen: string; + readonly lastSeen: string; + readonly libraryDir?: string; + readonly rulesVersion?: string; +} + +async function readRegistry(path: string): Promise { + return JSON.parse(await read(path)) as readonly RegistryEntry[]; +} + +/** The version this checkout's own manifest names -- never hard-coded, so a + * release bump never leaves this file asserting a stale number. */ +function currentVersion(): string { + const manifest = join(REPO_ROOT, 'apps', 'cli', 'package.json'); + const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown }; + if (typeof parsed.version !== 'string' || parsed.version === '') { + throw new Error(`no version in ${manifest}`); + } + return parsed.version; +} + +/** A newer final release than `version`, which must itself be a final + * release: `chooseUpdateTarget` only ever offers a final release a newer + * final release (packages/core/src/domain/version.ts), never a pre-release. */ +function newerRelease(version: string): string { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (match === null) { + throw new Error(`apps/cli/package.json's version is not a plain release: ${version}`); + } + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number(patch) + 1}`; +} + +/** + * The registry's document for `ailoud`, written to a fixture file -- never a + * server. See `AILOUD_PACKUMENTS` in `scripts/retire-prereleases.test.mjs`, + * whose own doc comment tells the story this follows: an HTTP stub tried + * here once left a throwing test's server handle open, and the leaked + * handle hung the entire suite with no failing test to point at, because a + * per-test timeout does not apply to a handle nobody closed. A file behind + * an environment variable cannot leak that way -- there is no handle to + * leave open. + */ +function packumentFixture(dir: string, versions: readonly string[]): string { + const path = join(dir, 'packuments.json'); + const one = { versions: Object.fromEntries(versions.map((v) => [v, {}])) }; + writeFileSync(path, JSON.stringify({ ailoud: one })); + return path; +} + +/** + * A directory holding an `npm` that only ever answers `root -g`, with + * `rootDir` -- for putting first on PATH so `self update --dry-run` detects + * an `npm-global` install instead of this repository's natural refusal, the + * one other branch the design calls for exercising. Anything else asked of + * it fails loudly rather than doing something unexpected. + */ +function stubNpmRoot(dir: string, rootDir: string): string { + const bin = join(dir, 'stub-bin'); + mkdirSync(bin, { recursive: true }); + const script = join(bin, 'npm'); + writeFileSync( + script, + `#!/bin/sh\n` + + `if [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n` + + ` echo "${rootDir}"\n` + + ` exit 0\n` + + `fi\n` + + `echo "stub npm: unexpected args: $@" >&2\n` + + `exit 1\n`, + ); + chmodSync(script, 0o755); + return bin; +} + +describe('ailoud self check', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('reports the target it would take, against a stub registry', async () => { + const current = currentVersion(); + const target = newerRelease(current); + const fixture = packumentFixture(sandbox.home, [current, target]); + + const result = await sandbox.run(['self', 'check', '--json'], { + env: { AILOUD_PACKUMENTS: fixture }, + }); + + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout.trim()) as { + current: string; + target: string | null; + updatable: boolean; + }; + expect(parsed.current).toBe(current); + expect(parsed.target).toBe(target); + expect(parsed.updatable).toBe(true); + }); +}); + +describe('ailoud self update', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('prints a plan and changes nothing with --dry-run', async () => { + const current = currentVersion(); + const target = newerRelease(current); + const fixture = packumentFixture(sandbox.home, [current, target]); + const stubBin = stubNpmRoot(sandbox.home, REPO_ROOT); + + const registryBefore = await exists(join(sandbox.dataDir, 'projects.json')); + expect(registryBefore).toBe(false); + + const result = await sandbox.run(['self', 'update', '--dry-run'], { + env: { + AILOUD_PACKUMENTS: fixture, + PATH: `${stubBin}:${process.env['PATH'] ?? ''}`, + }, + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain(`Current version: ${current}`); + expect(result.stdout).toContain(`Target version: ${target}`); + expect(result.stdout).toMatch(/Install command: .*npm .*install -g ailoud@/); + expect(result.stdout).toContain('Dry run: nothing was changed.'); + + // No install: nothing this test could observe short of a real global + // write, which the anchored, never-PATH-resolved install command and + // the early dry-run return both already rule out. + // No sweep, no log write, no registry write. + expect(await exists(join(sandbox.dataDir, 'update.log'))).toBe(false); + expect(await exists(join(sandbox.dataDir, 'projects.json'))).toBe(false); + }); + + it('refuses to update a project dependency and names the command to run', async () => { + const current = currentVersion(); + const target = newerRelease(current); + const fixture = packumentFixture(sandbox.home, [current, target]); + + const result = await sandbox.run(['self', 'update'], { + env: { AILOUD_PACKUMENTS: fixture }, + }); + + // This repository's own checkout is never an npm-global or pnpm-global + // install, so detectInstallMethod refuses -- naturally, with no stubbing + // -- and names a command to run instead of installing anything. + expect(result.code).toBe(0); + expect(result.stdout).toMatch( + /npm install -g ailoud@|pnpm add -g ailoud@|add command in|npx ailoud@/, + ); + }); + + it('refuses under --force with a non-zero exit', async () => { + const current = currentVersion(); + const target = newerRelease(current); + const fixture = packumentFixture(sandbox.home, [current, target]); + + const result = await sandbox.run(['self', 'update', '--force'], { + env: { AILOUD_PACKUMENTS: fixture }, + }); + + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/cannot install this way/); + }); +}); + +describe('ailoud self sync', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('refreshes a rules block, and is idempotent on a second run', async () => { + // A fresh project has no CLAUDE.md, so `mcp install` writes the block to + // the preferred candidate, .claude/CLAUDE.md -- that is the file `self + // sync` must find stale and rewrite for this test to prove anything. + const claudeMd = join(sandbox.projectDir, '.claude', 'CLAUDE.md'); + + // `mcp install --location local` registers the project itself, with this + // build's rules version, the moment it succeeds (see + // `registerAfterInstall` in apps/cli/src/commands/mcpInstall.ts) -- no + // second command is needed to put it in projects.json. + await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local']); + + // Simulate an older ailoud having written a different block, the same + // way e2e/tests/mcp-install.spec.ts does for `mcp update`. + const before = await read(claudeMd); + const stale = before.replace( + /[\s\S]*/, + `${START}\nold text\n`, + ); + await writeFile(claudeMd, stale, 'utf8'); + + const first = await sandbox.run(['self', 'sync']); + expect(first.code).toBe(0); + expect(first.stdout).toContain(`refreshed: ${sandbox.projectDir}`); + const refreshed = await read(claudeMd); + expect(refreshed).not.toContain('old text'); + expect(refreshed).toContain('search_transcripts'); + + // Idempotent: a second sweep with nothing stale must say `current`, not + // `refreshed` -- a sweep over many projects must never claim an edit it + // did not make, which is the whole reason this command exists. + const second = await sandbox.run(['self', 'sync']); + expect(second.code).toBe(0); + expect(second.stdout).toContain(`current: ${sandbox.projectDir}`); + expect(second.stdout).not.toContain(`refreshed: ${sandbox.projectDir}`); + expect(await read(claudeMd)).toBe(refreshed); + }); + + it('reports and prunes a project whose directory is gone', async () => { + const goneDir = join(sandbox.home, 'gone-project'); + await mkdir(goneDir, { recursive: true }); + await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local'], { + cwd: goneDir, + }); + + const registryPath = join(sandbox.dataDir, 'projects.json'); + const before = await readRegistry(registryPath); + expect(before.some((entry) => entry.path === goneDir)).toBe(true); + + await rm(goneDir, { recursive: true, force: true }); + + const result = await sandbox.run(['self', 'sync']); + expect(result.code).toBe(0); + expect(result.stdout).toContain(`gone: ${goneDir}`); + + const after = await readRegistry(registryPath); + expect(after.some((entry) => entry.path === goneDir)).toBe(false); + }); +}); + +describe('the project registry', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('records a project in projects.json after a command uses its library', async () => { + const registryPath = join(sandbox.dataDir, 'projects.json'); + expect(await exists(registryPath)).toBe(false); + + // `mcp install --location local` both creates the project library AND + // registers it (`registerAfterInstall` in + // apps/cli/src/commands/mcpInstall.ts) -- Task 10's own proof, kept + // honest here against the real binary. + const result = await sandbox.run([ + 'mcp', + 'install', + '--target', + 'claude', + '--location', + 'local', + ]); + expect(result.code).toBe(0); + + const registry = await readRegistry(registryPath); + expect(registry).toHaveLength(1); + expect(registry[0]?.path).toBe(sandbox.projectDir); + expect(registry[0]?.rulesVersion).toBeDefined(); + }); + + it('keeps projects.json valid when two commands run at once', async () => { + const dirA = join(sandbox.home, 'project-a'); + const dirB = join(sandbox.home, 'project-b'); + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + + // Each project's OWN local library is created first, sequentially: with + // neither directory holding a `.ailoud/` yet, `mcp install` falls back to + // opening the shared PER-USER library just long enough to write one, and + // running that step for both projects at once would race two processes + // on THAT single sqlite file -- a real hazard, but a different one from + // what this spec is about. One at a time here isolates the race to the + // one file this spec targets: projects.json. + await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local'], { + cwd: dirA, + }); + await sandbox.run(['mcp', 'install', '--target', 'claude', '--location', 'local'], { + cwd: dirB, + }); + + const registryPath = join(sandbox.dataDir, 'projects.json'); + // Back-dated past rememberProject's 24-hour throttle (projects.ts), so + // the concurrent commands below actually write instead of silently + // no-op'ing on an entry seen moments ago. + const justRegistered = await readRegistry(registryPath); + const backdated = justRegistered.map((entry) => ({ + ...entry, + lastSeen: '2000-01-01T00:00:00.000Z', + })); + await writeFile(registryPath, `${JSON.stringify(backdated, null, 2)}\n`, 'utf8'); + + // NOW genuinely concurrent: two processes, each already holding its own + // project-local library (no shared sqlite file left to race on), both + // touching the one file that IS shared -- projects.json -- at once. + // `writeRegistry`'s own doc comment (projects.ts) is explicit that its + // re-read-before-rename only NARROWS the lost-update window to the + // rename itself, rather than closing it -- a write landing inside that + // window is expected to lose its OWN timestamp bump, self-healing on the + // next run, and is not this test's concern. What must never happen is + // the file going invalid, or either project's entry disappearing + // outright, which is what is asserted below. + // + // And this spec is NOT where the race guarantee is pinned. Two real + // subprocesses may simply not overlap inside the critical section, so a + // green run here is evidence rather than proof. The deterministic case -- + // a rival that commits between the re-read and the rename, every time -- + // lives in `apps/cli/src/projects.test.ts` under `RacingFs`. Read that + // one if you are changing `writeRegistry`; this one only catches a + // regression crude enough to survive real scheduling. + await Promise.all([sandbox.run(['ls'], { cwd: dirA }), sandbox.run(['ls'], { cwd: dirB })]); + + const registry = await readRegistry(registryPath); + expect(Array.isArray(registry)).toBe(true); + expect(registry).toHaveLength(2); + expect(registry.some((entry) => entry.path === dirA)).toBe(true); + expect(registry.some((entry) => entry.path === dirB)).toBe(true); + }); +}); + +it('leaves the repository working tree exactly as it found it', () => { + // Runs after every other spec's sandbox has been torn down. Compared + // against the snapshot taken before the suite started, not against + // "empty": asserting `git status --porcelain` is empty fails for any + // developer with uncommitted work already in progress, which is most of + // the time, and a check that fails for reasons unrelated to its subject + // is a check that gets ignored. Identical before/after still fails the + // instant any spec, or the harness itself, writes into the repository + // rather than into its own sandbox -- the one thing this test exists to + // catch. + expect(gitStatus()).toBe(statusBeforeSuite); +}); diff --git a/e2e/tests/setup.spec.ts b/e2e/tests/setup.spec.ts new file mode 100644 index 0000000..fbfa24b --- /dev/null +++ b/e2e/tests/setup.spec.ts @@ -0,0 +1,40 @@ +// End-to-end coverage for `ailoud setup`'s --model validation, driven through +// the built binary. Deliberately the ONLY setup spec in the no-tools project: +// a real install/download/switch belongs to the `tools` project (see +// jest.config.cjs), which only runs on a provisioned machine, and a bare CI +// runner cannot exercise one honestly. +// +// This one case works on a bare machine precisely because it is bare: every +// check runChecks runs here fails (no ffmpeg, no whisper.cpp, nothing +// configured), so runProvisioning always has a non-empty plan to build and +// always reaches chooseModel -- regardless of --model or of this change. What +// this spec actually proves is narrower, and unaffected by that: an unknown +// --model still raises resolveModelName's UsageError, by name, rather than +// being swallowed by the surrounding plumbing. +import { makeSandbox } from '../src/cli'; +import type { Sandbox } from '../src/cli'; + +jest.setTimeout(60_000); + +describe('ailoud setup --model', () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await sandbox.cleanup(); + }); + + it('rejects an unknown model name and lists the valid ones', async () => { + const result = await sandbox.run(['setup', '--model', 'ailoud-e2e-no-such-model']); + + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/unknown model "ailoud-e2e-no-such-model"/); + // The whole list, verbatim and in order: it is user-facing copy, and the + // order is the one the interactive picker shows (ascending by download + // size). A catalogue change should have to come past this assertion. + expect(result.stderr).toContain('tiny, base, small, large-v3-turbo-q5_0, large-v3'); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index f263d02..5bf3560 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,18 +12,55 @@ const LAYER_MAY_IMPORT = { export default tseslint.config( { - // scripts/** holds plain Node utility scripts (release tooling, not - // part of the typed packages/apps source tree); node --check is their - // syntax gate instead. ignores: [ '**/dist/**', '**/coverage/**', '**/node_modules/**', // mkdocs build output: third-party minified JS, not ours to lint. 'site/**', + // Git-ignored scratch (see .gitignore): throwaway measurement scripts + // that are not part of the project and hold nothing worth linting. A + // stray one there failed `pnpm lint` for the whole repository. + 'tmp/**', + ], + }, + { + // The build's configuration and the release scripts. Both were ignored, + // which meant every editor reported "File ignored because of a matching + // ignore pattern" on opening one, and a mistake in them was caught by + // nothing -- `node --check` was the only gate on scripts/, and it sees + // syntax, not an unused variable or a misspelled identifier. They are + // Node, not part of the typed source tree, so they get the recommended + // rules and Node globals rather than the type-aware config. + // `e2e/src/*.cjs` are jest setupFiles rather than configs by name, but + // they are the same category: plain Node CommonJS outside the typed + // source tree. + files: [ + '*.config.{js,mjs,cjs,ts}', '**/*.config.{js,mjs,cjs,ts}', - 'scripts/**', + 'scripts/**/*.mjs', + 'e2e/src/**/*.cjs', ], + languageOptions: { + // The Node globals these files actually use. Spelled out rather than + // pulled from a globals package: it is a short list, and a new name + // appearing here should be a deliberate addition. + globals: { + console: 'readonly', + process: 'readonly', + module: 'writable', + require: 'readonly', + __dirname: 'readonly', + Buffer: 'readonly', + URL: 'readonly', + fetch: 'readonly', + }, + }, + rules: { + // A .cjs file uses require/module, which the type-aware rules would + // otherwise flag in a "type": "module" workspace. + '@typescript-eslint/no-require-imports': 'off', + }, }, js.configs.recommended, ...tseslint.configs.recommended, @@ -40,13 +77,28 @@ export default tseslint.config( files: ['packages/*/src/**/*.ts', 'apps/*/src/**/*.ts'], plugins: { boundaries }, settings: { + // Both src and dist. A cross-package import can be written two ways: + // `../../providers/src/index.js` resolves to a source file, but + // `@ailoud/providers` resolves through node_modules to the built + // `dist/index.js` -- which matched no element, so the rule classified it + // as unknown and said nothing at all. Listing dist under the same type + // makes both forms the same violation. It does mean the second form is + // only caught once dist exists, which is why the gate and CI both build + // before they lint. 'boundaries/elements': [ - { type: 'core', pattern: 'packages/core/src/**' }, - { type: 'providers', pattern: 'packages/providers/src/**' }, - { type: 'cli', pattern: 'apps/cli/src/**' }, + { type: 'core', pattern: ['packages/core/src/**', 'packages/core/dist/**'] }, + { + type: 'providers', + pattern: ['packages/providers/src/**', 'packages/providers/dist/**'], + }, + { type: 'cli', pattern: ['apps/cli/src/**', 'apps/cli/dist/**'] }, ], + // The root tsconfig, which includes every package's sources, rather + // than a glob over the per-package ones: the resolver warns about + // multiple projects, and one project covering the whole workspace is + // both quieter and faster. 'import/resolver': { - typescript: { project: ['packages/*/tsconfig.json', 'apps/*/tsconfig.json'] }, + typescript: { project: 'tsconfig.json' }, }, }, rules: { @@ -54,10 +106,12 @@ export default tseslint.config( 'error', { default: 'disallow', - policies: Object.entries(LAYER_MAY_IMPORT).map(([from, to]) => ({ - from: { element: { type: from } }, - allow: { to: { element: { types: { anyOf: to } } } }, - })), + policies: [ + ...Object.entries(LAYER_MAY_IMPORT).map(([from, to]) => ({ + from: { element: { type: from } }, + allow: { to: { element: { types: { anyOf: to } } } }, + })), + ], }, ], }, diff --git a/fixtures/en-short.mkv b/fixtures/en-short.mkv new file mode 100644 index 0000000..0e38a2d --- /dev/null +++ b/fixtures/en-short.mkv @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f05f1d481205693d249df834dfb8fd7ef706db41932d07afca49a7cc3bdf2e3 +size 9879 diff --git a/fixtures/en-short.mov b/fixtures/en-short.mov new file mode 100644 index 0000000..24e5ea0 --- /dev/null +++ b/fixtures/en-short.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc8f99f5540c772a26017328a69c05f6a8c4d07e6f105fb00b03a3cd10176214 +size 13266 diff --git a/fixtures/en-short.mp4 b/fixtures/en-short.mp4 new file mode 100644 index 0000000..2009cac --- /dev/null +++ b/fixtures/en-short.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c984e208272ef1ae391e07d0a57fd5fa505ca40f4a0932429a366b968b414de6 +size 13215 diff --git a/fixtures/en-short.webm b/fixtures/en-short.webm new file mode 100644 index 0000000..57a0da4 --- /dev/null +++ b/fixtures/en-short.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c77373b0cd1330b4e64d1c120828efd3306a3856382c2737e175a73091d791f2 +size 9117 diff --git a/fixtures/noisy-short.txt b/fixtures/noisy-short.txt new file mode 100644 index 0000000..2fe6575 --- /dev/null +++ b/fixtures/noisy-short.txt @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog. diff --git a/fixtures/noisy-short.wav b/fixtures/noisy-short.wav new file mode 100644 index 0000000..182ffe8 --- /dev/null +++ b/fixtures/noisy-short.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31bf1884816fb904b966342ce94bab16821df6cb0cedf8d8d306e913c21e9d92 +size 79970 diff --git a/jest.config.cjs b/jest.config.cjs index 7fb320e..aa3d8c9 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -39,17 +39,46 @@ const shared = { module.exports = { maxWorkers: 2, detectOpenHandles: true, + // Also at the root, not only inside each project. Jest takes the per-test + // timeout from the GLOBAL config, and with `projects` a value set only on a + // project reaches `configs[].testTimeout` while `globalConfig.testTimeout` + // stays undefined -- so every test silently fell back to Jest's 5 s default. + // Locally that was masked: the transcribe specs were failing fast for a + // missing model, so nothing ran long enough to hit it. On CI, where the + // model is there, whisper takes tens of seconds and every one timed out. + testTimeout: 600_000, projects: [ { ...shared, displayName: 'no-tools', - testMatch: ['/e2e/tests/mcp-install.spec.ts'], + testMatch: [ + '/e2e/tests/mcp-install.spec.ts', + '/e2e/tests/self-update.spec.ts', + '/e2e/tests/setup.spec.ts', + '/e2e/tests/completions.spec.ts', + // resources.spec.ts holds two describe blocks: one built entirely on + // stub binaries (belongs here) and one that drives real ffmpeg/ + // whisper-cli/a model (belongs only to `tools`, below). A single + // spec file cannot be split across projects by testMatch alone -- + // that is file granularity, not describe-block granularity -- so + // setupNoTools.cjs/setupTools.cjs (below) set an env flag the spec + // itself reads to skip the real-audio block whenever this project is + // the one running it. + '/e2e/tests/resources.spec.ts', + ], + setupFiles: ['/e2e/src/setupNoTools.cjs'], }, { ...shared, displayName: 'tools', testMatch: ['/e2e/tests/**/*.spec.ts'], - testPathIgnorePatterns: ['/e2e/tests/mcp-install\\.spec\\.ts'], + testPathIgnorePatterns: [ + '/e2e/tests/mcp-install\\.spec\\.ts', + '/e2e/tests/self-update\\.spec\\.ts', + '/e2e/tests/setup\\.spec\\.ts', + '/e2e/tests/completions\\.spec\\.ts', + ], + setupFiles: ['/e2e/src/setupTools.cjs'], }, ], }; diff --git a/mkdocs.yml b/mkdocs.yml index 8d71779..cd6d3d3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,14 +43,15 @@ markdown_extensions: nav: - Home: index.md - Getting Started: getting-started.md + - MCP: mcp.md - Usage: - Recordings: usage/recordings.md - Search: usage/search.md - Summaries: usage/summaries.md - Templates: usage/templates.md - Configuration: usage/configuration.md + - Updating: usage/updating.md - CLI Reference: usage/cli.md - - MCP: mcp.md - Development: - Architecture: development/architecture.md - Development: development/development.md diff --git a/package.json b/package.json index 78a53db..6ffeace 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "ailoud-workspace", - "version": "0.0.0", + "version": "1.2.0", "private": true, "type": "module", "description": "Multilingual audio-to-text CLI with a recording library and LLM summaries", + "author": "Lorem Dev ", "license": "Apache-2.0", "packageManager": "pnpm@11.9.0", "engines": { @@ -18,11 +19,14 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "pnpm -r typecheck", - "test": "vitest run", + "test": "NODE_OPTIONS=--disable-warning=ExperimentalWarning vitest run", "test:watch": "vitest", - "test:cov": "vitest run --coverage", + "test:cov": "NODE_OPTIONS=--disable-warning=ExperimentalWarning vitest run --coverage", "test:e2e": "pnpm build && jest --config jest.config.cjs", - "test:e2e:no-tools": "pnpm build && jest --config jest.config.cjs --selectProjects no-tools" + "test:e2e:no-tools": "pnpm build && jest --config jest.config.cjs --selectProjects no-tools", + "retire": "node scripts/retire-prereleases.mjs", + "docs:build": "uv run --with-requirements docs/requirements.txt mkdocs build --strict", + "docs:serve": "uv run --with-requirements docs/requirements.txt mkdocs serve" }, "devDependencies": { "@eslint/js": "10.0.1", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..cec11c7 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,16 @@ +# @ailoud/core + +The domain layer of [AILoud](https://github.com/lorem-dev/ailoud): the model, +the ports, and the pure logic that does no I/O. + +Every effect reaches this package as a port -- `Fs`, `Clock`, `AudioTool`, +`TranscriptionProvider`, `RecordingStore`, `Summarizer` -- implemented in +[`@ailoud/providers`](https://www.npmjs.com/package/@ailoud/providers) and wired +together by the [`ailoud`](https://www.npmjs.com/package/ailoud) CLI. A lint +rule fails the build if anything here imports `node:fs`, `node:child_process` +or a network module. + +Published because the CLI depends on it. The interfaces are not stable yet, and +there is no reason to depend on this package directly. + +Documentation: diff --git a/packages/core/package.json b/packages/core/package.json index bd9f524..8f8319b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@ailoud/core", - "version": "0.0.0", + "version": "1.2.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -19,6 +19,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "description": "AILoud domain model, ports and pure logic: the layer that does no I/O", + "author": "Lorem Dev ", "license": "Apache-2.0", "homepage": "https://lorem-dev.github.io/ailoud/", "bugs": { diff --git a/packages/core/src/audio/noise.test.ts b/packages/core/src/audio/noise.test.ts new file mode 100644 index 0000000..51fe6bd --- /dev/null +++ b/packages/core/src/audio/noise.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { DENOISE_MODES, NOISY_SNR_DB, shouldDenoise, snrDb } from './noise.js'; + +describe('snrDb', () => { + it('subtracts the noise floor from the rms level', () => { + expect(snrDb({ rmsDb: -16.17, noiseFloorDb: -43.3 })).toBeCloseTo(27.13, 2); + }); + + it('answers null when the floor is unavailable', () => { + // -inf from ffmpeg arrives here as null, and five of the eight project + // fixtures are this case. It is the common path, not an edge case. + expect(snrDb({ rmsDb: -15.38, noiseFloorDb: null })).toBeNull(); + }); + + it('answers null when the rms level is unavailable', () => { + expect(snrDb({ rmsDb: null, noiseFloorDb: -40 })).toBeNull(); + }); +}); + +describe('shouldDenoise', () => { + it('never denoises in off mode, however noisy', () => { + expect(shouldDenoise('off', { rmsDb: -22.18, noiseFloorDb: -38.53 })).toBe(false); + }); + + it('always denoises in on mode, however clean', () => { + expect(shouldDenoise('on', { rmsDb: -16.17, noiseFloorDb: -43.3 })).toBe(true); + }); + + it('always denoises in on mode even with no measurement at all', () => { + // "on" is an instruction, not a hypothesis: it must not depend on a + // measurement that may have failed. + expect(shouldDenoise('on', { rmsDb: null, noiseFloorDb: null })).toBe(true); + }); + + it('leaves audio with no measurable noise floor alone in auto mode', () => { + // The mixed-short.wav case: ffmpeg reports -inf. That means nothing to + // remove, never "infinitely noisy". + expect(shouldDenoise('auto', { rmsDb: -15.38, noiseFloorDb: null })).toBe(false); + }); + + it('leaves audio with an unusable measurement alone in auto mode', () => { + expect(shouldDenoise('auto', { rmsDb: null, noiseFloorDb: -38.94 })).toBe(false); + }); + + it('denoises the noisy fixture in auto mode', () => { + // fixtures/noisy-short.wav, measured: 16.35 dB. + expect(shouldDenoise('auto', { rmsDb: -22.18, noiseFloorDb: -38.53 })).toBe(true); + }); + + it('leaves the two clean measurable fixtures alone in auto mode', () => { + // en-short.wav at 27.14 dB and ru-short.wav at 26.16 dB. These are the + // regression this threshold exists to avoid. + expect(shouldDenoise('auto', { rmsDb: -16.17, noiseFloorDb: -43.3 })).toBe(false); + expect(shouldDenoise('auto', { rmsDb: -17.42, noiseFloorDb: -43.58 })).toBe(false); + }); + + it('keeps real margin on both sides of the threshold', () => { + // Not a restatement of the constant: this asserts the gap the fixture + // table actually measured, so tightening the threshold toward either + // real fixture fails here rather than silently in production. + expect(NOISY_SNR_DB - 16.35).toBeGreaterThan(4); + expect(26.16 - NOISY_SNR_DB).toBeGreaterThan(4); + }); +}); + +describe('DENOISE_MODES', () => { + it('lists every mode, for the CLI to validate against', () => { + expect(DENOISE_MODES).toEqual(['auto', 'on', 'off']); + }); +}); diff --git a/packages/core/src/audio/noise.ts b/packages/core/src/audio/noise.ts new file mode 100644 index 0000000..298a3c9 --- /dev/null +++ b/packages/core/src/audio/noise.ts @@ -0,0 +1,70 @@ +import type { NoiseProfile } from '../domain/ports.js'; + +export type DenoiseMode = 'auto' | 'on' | 'off'; + +/** Every accepted mode, in the order `--help` should list them. */ +export const DENOISE_MODES: readonly DenoiseMode[] = ['auto', 'on', 'off']; + +/** + * The signal-to-noise ratio below which `auto` denoises. + * + * MEASURED, not chosen. Against this project's own fixtures, at 16 kHz mono: + * + * noisy-short.wav 16.35 dB denoised + * ru-short.wav 26.16 dB left alone + * en-short.wav 27.14 dB left alone + * + * 22 sits between them with 5.6 dB of margin below and 4.2 dB above. The + * other five fixtures report no noise floor at all and never reach this + * comparison. Moving this number is a measurement, not an opinion: rebuild + * the table before touching it. + * + * READ THIS BEFORE RELYING ON THE THRESHOLD. A benchmark on 2026-09-08 -- + * six corpora, eight whisper models, noise conditions from clean down to + * 0 dB, about 26 paired comparisons resolved by bootstrap over clips -- + * found NO case where running the chain improved a transcript, and five + * where it made one significantly worse. That is why `audio.denoise` + * defaults to `off` and this comparison is normally never reached. + * + * Two things about the measurement itself are worth knowing: + * + * - `RMS level - noise floor` is a proxy, not a signal-to-noise ratio. On + * pink noise the floor astats reports sits about 6 dB above the noise's + * own RMS, and the figure moves with clip length and with how much of a + * clip is silence. + * - It cannot see codec damage. A 24 kbit/s conference recording that + * sounds obviously degraded measured 32 dB here, and two of its three + * windows reported no floor at all. + * + * Where the threshold DOES fire on real audio -- a far-field meeting mic + * measures 4 to 15 dB -- denoising changed the word error rate by 0.00 + * points for the default model and made `large-v3` significantly worse. + */ +export const NOISY_SNR_DB = 22; + +export function snrDb(profile: NoiseProfile): number | null { + const { rmsDb, noiseFloorDb } = profile; + if (rmsDb === null || noiseFloorDb === null) return null; + if (!Number.isFinite(rmsDb) || !Number.isFinite(noiseFloorDb)) return null; + return rmsDb - noiseFloorDb; +} + +/** + * Whether to run the denoising chain over this file. + * + * `on` deliberately ignores the profile entirely: it is an instruction, and + * making it depend on a measurement that may have failed would turn an + * explicit request into a silent no-op. + * + * Every `auto` path that lacks evidence answers false. Denoising was + * measured to cost accuracy on some material and to gain it on none, so + * "no usable measurement" must not be read as "probably noisy" -- see + * NOISY_SNR_DB above. + */ +export function shouldDenoise(mode: DenoiseMode, profile: NoiseProfile): boolean { + if (mode === 'off') return false; + if (mode === 'on') return true; + const snr = snrDb(profile); + if (snr === null) return false; + return snr < NOISY_SNR_DB; +} diff --git a/packages/core/src/domain/llmHost.test.ts b/packages/core/src/domain/llmHost.test.ts new file mode 100644 index 0000000..61f8050 --- /dev/null +++ b/packages/core/src/domain/llmHost.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { isHostedLlm, withoutTrailingSlashes } from './llmHost.js'; + +describe('isHostedLlm', () => { + it('recognises the two hosted APIs', () => { + expect(isHostedLlm('https://api.openai.com/v1')).toBe(true); + expect(isHostedLlm('https://api.anthropic.com')).toBe(true); + }); + + it('is not fooled by a host that merely starts with one', () => { + // The defect this replaced: `startsWith('https://api.openai.com')` and + // `/api\.(openai|anthropic)\.com/` both said yes to these, and the part + // of a hostname that decides where a request goes is the end of it. + expect(isHostedLlm('https://api.openai.com.example.net/v1')).toBe(false); + expect(isHostedLlm('https://api.anthropic.com.example.net')).toBe(false); + }); + + it('is not fooled by the name appearing elsewhere in the URL', () => { + expect(isHostedLlm('https://example.net/?upstream=api.openai.com')).toBe(false); + expect(isHostedLlm('https://example.net/api.openai.com')).toBe(false); + }); + + it('treats a local server as not hosted', () => { + expect(isHostedLlm('http://localhost:11434/v1')).toBe(false); + expect(isHostedLlm('http://127.0.0.1:8080')).toBe(false); + }); + + it('ignores case in the hostname, as DNS does', () => { + expect(isHostedLlm('https://API.OpenAI.com/v1')).toBe(true); + }); + + it('says no to something that is not a URL', () => { + // Reported as a configuration error elsewhere. Calling it hosted here + // would demand an API key for a value that cannot address anything. + expect(isHostedLlm('api.openai.com')).toBe(false); + expect(isHostedLlm('')).toBe(false); + }); +}); + +describe('withoutTrailingSlashes', () => { + it('strips one slash and many', () => { + expect(withoutTrailingSlashes('https://x.test/')).toBe('https://x.test'); + expect(withoutTrailingSlashes('https://x.test/v1///')).toBe('https://x.test/v1'); + }); + + it('leaves a value with none alone', () => { + expect(withoutTrailingSlashes('https://x.test/v1')).toBe('https://x.test/v1'); + }); + + it('handles a value that is only slashes without backtracking', () => { + // The reason this is a loop and not `replace(/\/+$/, '')`. + expect(withoutTrailingSlashes('/'.repeat(50_000))).toBe(''); + }); +}); diff --git a/packages/core/src/domain/llmHost.ts b/packages/core/src/domain/llmHost.ts new file mode 100644 index 0000000..358750e --- /dev/null +++ b/packages/core/src/domain/llmHost.ts @@ -0,0 +1,37 @@ +/** + * Whether a language-model endpoint is one of the hosted APIs. + * + * By hostname, compared exactly. Two places used to ask this with a substring + * -- `baseUrl.startsWith('https://api.openai.com')` and + * `/api\.(openai|anthropic)\.com/.test(baseUrl)` -- and both answered yes for + * `https://api.openai.com.example.net/v1`, where the interesting part of the + * name is what follows. A local server, which is the case these checks exist + * to spare, is any other host. + */ +const HOSTED_HOSTS: readonly string[] = ['api.openai.com', 'api.anthropic.com']; + +export function isHostedLlm(baseUrl: string): boolean { + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + // Not a URL at all. Reported elsewhere as a configuration error; treating + // it as hosted here would demand an API key for a value that cannot + // address anything. + return false; + } + return HOSTED_HOSTS.includes(parsed.hostname.toLowerCase()); +} + +/** + * `baseUrl` without trailing slashes, so a path can be appended to it. + * + * Written as a loop rather than `replace(/\/+$/, '')`: on a value ending in + * many slashes that pattern backtracks, which is a denial of service when the + * value comes from anywhere but us. There is no regex worth that here. + */ +export function withoutTrailingSlashes(baseUrl: string): string { + let end = baseUrl.length; + while (end > 0 && baseUrl[end - 1] === '/') end -= 1; + return baseUrl.slice(0, end); +} diff --git a/packages/core/src/domain/ports.ts b/packages/core/src/domain/ports.ts index 1d0fc68..a28eb17 100644 --- a/packages/core/src/domain/ports.ts +++ b/packages/core/src/domain/ports.ts @@ -7,6 +7,8 @@ import type { Summary, Transcript, } from './model.js'; +import type { PublishedVersion } from './version.js'; +import type { DenoiseMode } from '../audio/noise.js'; export interface Clock { nowIso(): string; @@ -61,6 +63,31 @@ export interface Fs { writeTextFile(path: string, content: string): Promise; /** Reads text. Rejects when the file is not there -- callers check `exists` first. */ readTextFile(path: string): Promise; + /** + * Renames within one filesystem, replacing the target. Atomic, which is why + * it exists: callers write a temporary file beside the real one and rename it + * over the top, so a reader never sees half a file. + */ + rename(from: string, to: string): Promise; +} + +/** + * What a noise measurement of one file found. + * + * Both fields nullable, both nulls meaning the same thing: no usable number. + * `noiseFloorDb` is null when ffmpeg reported `-inf`, which is what audio + * with no measurable noise produces -- five of this project's eight fixtures. + */ +export interface NoiseProfile { + readonly noiseFloorDb: number | null; + readonly rmsDb: number | null; +} + +/** What `toWav16kMono` did, beyond converting. */ +export interface WavPrepared { + /** True when the denoising chain was applied to `output`. */ + readonly denoised: boolean; + readonly profile: NoiseProfile; } export interface AudioTool { @@ -72,7 +99,26 @@ export interface AudioTool { * splitting it would double the cost of importing every file. */ probe(path: string): Promise<{ durationMs: number; recordedAt: string | null }>; - toWav16kMono(input: string, output: string): Promise; + /** + * Converts to the 16 kHz mono wav every engine here is fed, and -- when + * asked to -- measures the result and denoises it in place. + * + * The measurement and the filtering live behind this one call rather than + * beside it, on purpose. This method has exactly two production call sites, + * both in the transcribe pipeline, and separate port methods would have put + * a second temp file and a second nested try/finally into both the + * single-pass and the multilingual path. That is the most fragile code in + * the project, and a feature five of eight fixtures never even trigger does + * not get to restructure it. + * + * Omitting `opts` means no measurement and no filtering, which is what + * every caller predating this option already expects. + */ + toWav16kMono( + input: string, + output: string, + opts?: { readonly denoise?: DenoiseMode }, + ): Promise; /** * Writes the audio between `startMs` and `endMs` to `output`. This is the * audio-splitting work M1 deferred, in the shape the multilingual path @@ -92,7 +138,20 @@ export interface TranscriptionProvider { }; transcribe( audioPath: string, - opts: { readonly language?: string; readonly model?: string }, + opts: { + readonly language?: string; + readonly model?: string; + /** + * Called with how far along this one call is, 0..1, when the provider + * can tell. Optional on both sides: a provider that cannot report + * progress simply never calls it, and a caller that does not care + * omits it. + * + * A provider must not let this throw into its own work. See the + * enrichment rule in pipelines/transcribe.ts. + */ + readonly onProgress?: (fraction: number) => void; + }, ): Promise<{ language: string; model: string; segments: RawSegment[] }>; /** * Detects the language spoken in `audioPath` without transcribing it. @@ -304,3 +363,20 @@ export interface ManagedRecordingStore extends RecordingStore { /** SQLite's own `PRAGMA integrity_check` result; "ok" means the database is healthy. */ integrityCheck(): string; } + +/** + * What versions of a package exist. Implemented over the npm registry in + * packages/providers; a port because packages/core reaches no network. + */ +export interface VersionSource { + /** + * `signal` exists so ONE implementation can serve both callers. Without it, + * the background update check could not abandon a request promptly and so + * grew a second HTTP client of its own -- which then drifted, missing a + * guard the first one had. Measured on Node 24: aborting a `fetch` leaves + * the process alive for about 10.5 SECONDS, while `https.request`'s native + * `signal` releases it in about 60ms. That measurement is why the provider + * uses `https.request`, and why this parameter is not optional cosmetics. + */ + published(packageName: string, signal?: AbortSignal): Promise; +} diff --git a/packages/core/src/domain/version.test.ts b/packages/core/src/domain/version.test.ts new file mode 100644 index 0000000..edb8327 --- /dev/null +++ b/packages/core/src/domain/version.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { chooseUpdateTarget, compareVersions, parseVersion } from './version.js'; + +const published = (...versions: string[]) => + versions.map((version) => ({ version, deprecated: false })); + +describe('parseVersion', () => { + it('reads a final release', () => { + expect(parseVersion('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, pre: null }); + }); + + it('reads a dev snapshot', () => { + expect(parseVersion('1.2.3-dev.4')).toEqual({ + major: 1, + minor: 2, + patch: 3, + pre: { kind: 'dev', n: 4 }, + }); + }); + + it('reads a release candidate', () => { + expect(parseVersion('1.2.3-rc.1')?.pre).toEqual({ kind: 'rc', n: 1 }); + }); + + it.each(['', '1.2', '1.2.3.4', 'v1.2.3', '1.2.3-beta.1', '1.2.3-dev', '1.2.3-dev.x'])( + 'refuses %o', + (text) => { + expect(parseVersion(text)).toBeNull(); + }, + ); +}); + +describe('compareVersions', () => { + it('sorts a release above its own pre-releases', () => { + // Semver precedence, and the one people get wrong: 1.0.0 > 1.0.0-dev.5. + const release = parseVersion('1.0.0')!; + const snapshot = parseVersion('1.0.0-dev.5')!; + expect(compareVersions(release, snapshot)).toBeGreaterThan(0); + }); + + it('sorts pre-releases of one base by their index', () => { + expect( + compareVersions(parseVersion('1.0.0-dev.10')!, parseVersion('1.0.0-dev.9')!), + ).toBeGreaterThan(0); + }); + + it('sorts rc above dev of the same base', () => { + expect( + compareVersions(parseVersion('1.0.0-rc.1')!, parseVersion('1.0.0-dev.9')!), + ).toBeGreaterThan(0); + }); + + it('is zero for equal versions', () => { + expect(compareVersions(parseVersion('2.0.1-rc.3')!, parseVersion('2.0.1-rc.3')!)).toBe(0); + }); +}); + +describe('chooseUpdateTarget', () => { + it('offers the newest final to a final', () => { + expect(chooseUpdateTarget('1.0.0', published('1.0.0', '1.0.1', '1.1.0'))).toBe('1.1.0'); + }); + + it('offers a newer dev of the same base to a dev', () => { + expect(chooseUpdateTarget('1.0.0-dev.3', published('1.0.0-dev.3', '1.0.0-dev.4'))).toBe( + '1.0.0-dev.4', + ); + }); + + it('offers the very next dev, so i >= 1', () => { + expect(chooseUpdateTarget('1.0.0-dev.1', published('1.0.0-dev.2'))).toBe('1.0.0-dev.2'); + }); + + it('prefers the final over a newer snapshot of the same base', () => { + expect(chooseUpdateTarget('1.0.0-dev.3', published('1.0.0-dev.4', '1.0.0'))).toBe('1.0.0'); + }); + + it('refuses to move a final onto a pre-release', () => { + expect(chooseUpdateTarget('1.0.0', published('1.1.0-dev.1', '1.1.0-rc.1'))).toBeNull(); + }); + + it('refuses to cross pre-release kind', () => { + expect(chooseUpdateTarget('1.0.0-dev.3', published('1.0.0-rc.1'))).toBeNull(); + }); + + it('refuses a pre-release of another base', () => { + expect(chooseUpdateTarget('1.0.0-dev.3', published('1.1.0-dev.1'))).toBeNull(); + }); + + it('never offers a deprecated version', () => { + // What `pnpm retire` leaves behind: every superseded snapshot deprecated. + const available = [ + { version: '1.0.0-dev.9', deprecated: true }, + { version: '1.0.0', deprecated: false }, + ]; + expect(chooseUpdateTarget('1.0.0-dev.3', available)).toBe('1.0.0'); + }); + + it('answers null when nothing is newer', () => { + expect(chooseUpdateTarget('1.1.0', published('1.0.0', '1.1.0'))).toBeNull(); + }); + + it('works when the running version was never published', () => { + // A locally built snapshot. Targets come from ordering, not from finding + // the current version in the list. + expect(chooseUpdateTarget('1.1.0-dev.0', published('1.1.0-dev.1'))).toBe('1.1.0-dev.1'); + }); + + it('ignores versions it cannot parse', () => { + expect(chooseUpdateTarget('1.0.0', published('1.0.1', 'not-a-version'))).toBe('1.0.1'); + }); + + it('throws when its own version is unparseable', () => { + expect(() => chooseUpdateTarget('nonsense', published('1.0.0'))).toThrow(/nonsense/); + }); +}); diff --git a/packages/core/src/domain/version.ts b/packages/core/src/domain/version.ts new file mode 100644 index 0000000..ba103cb --- /dev/null +++ b/packages/core/src/domain/version.ts @@ -0,0 +1,98 @@ +/** A version this project produces: `X.Y.Z`, `X.Y.Z-dev.N` or `X.Y.Z-rc.N`. */ +export interface Version { + readonly major: number; + readonly minor: number; + readonly patch: number; + /** null for a final release. */ + readonly pre: { readonly kind: PreKind; readonly n: number } | null; +} + +export type PreKind = 'dev' | 'rc'; + +/** One version as the registry reports it. */ +export interface PublishedVersion { + readonly version: string; + readonly deprecated: boolean; +} + +// Deliberately narrow. The only pre-release kinds this project publishes are +// `dev` and `rc` (see the tag table in AGENTS.md), and a parser that accepted +// `beta` would be inventing a policy for a version nobody can produce. +const PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-(dev|rc)\.(\d+))?$/; + +export function parseVersion(text: string): Version | null { + const match = PATTERN.exec(text); + if (match === null) return null; + const [, major, minor, patch, kind, n] = match; + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + pre: kind === undefined ? null : { kind: kind as PreKind, n: Number(n) }, + }; +} + +/** rc outranks dev, matching semver's dictionary order on the identifier. */ +const KIND_RANK: Record = { dev: 0, rc: 1 }; + +export function compareVersions(a: Version, b: Version): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + // Semver precedence: a pre-release sorts BELOW the release it leads to, so + // 1.0.0 > 1.0.0-dev.5. This is what makes "a snapshot can move to its own + // final release" fall out of the ordering instead of needing a special case. + if (a.pre === null && b.pre === null) return 0; + if (a.pre === null) return 1; + if (b.pre === null) return -1; + if (a.pre.kind !== b.pre.kind) return KIND_RANK[a.pre.kind] - KIND_RANK[b.pre.kind]; + return a.pre.n - b.pre.n; +} + +/** + * The newest version `current` is allowed to move to, or null. + * + * The policy, which exists because a snapshot is not a lesser release but a + * different line of them: + * + * - A final release moves only to a newer final release. Offering a snapshot + * to someone on a release would hand them less tested code than they have. + * - A pre-release moves to a newer pre-release OF THE SAME KIND AND BASE, or + * to any newer final. `1.0.0-dev.3` may take `1.0.0-dev.4` or `1.0.0`, and + * may not take `1.1.0-dev.1` -- that is a different version's line of + * snapshots, and stepping sideways into it skips whatever `1.0.0` became. + * - A deprecated version is never a target. `pnpm retire` deprecates every + * superseded snapshot, so this is what keeps a retired `-dev.9` from being + * offered in place of the `1.0.0` that replaced it. + */ +export function chooseUpdateTarget( + current: string, + available: readonly PublishedVersion[], +): string | null { + const from = parseVersion(current); + if (from === null) { + throw new Error( + `ailoud cannot read its own version ${JSON.stringify(current)}, so it cannot tell what to update to.`, + ); + } + + let best: { text: string; version: Version } | null = null; + for (const candidate of available) { + if (candidate.deprecated) continue; + const to = parseVersion(candidate.version); + if (to === null) continue; + if (compareVersions(to, from) <= 0) continue; + if (!isEligible(from, to)) continue; + if (best === null || compareVersions(to, best.version) > 0) { + best = { text: candidate.version, version: to }; + } + } + return best?.text ?? null; +} + +function isEligible(from: Version, to: Version): boolean { + if (to.pre === null) return true; + if (from.pre === null) return false; + if (from.pre.kind !== to.pre.kind) return false; + return to.major === from.major && to.minor === from.minor && to.patch === from.patch; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2eb2e66..044ae4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export type { Fs, Ids, ManagedRecordingStore, + NoiseProfile, RecordingListFilter, RecordingStore, SegmentSearchFilter, @@ -25,6 +26,8 @@ export type { TempDir, TempFile, TranscriptionProvider, + VersionSource, + WavPrepared, } from './domain/ports.js'; export type { Migration } from './db/schema.js'; @@ -62,9 +65,13 @@ export { export { AiloudError, FailureError, UsageError, EnvironmentError } from './domain/errors.js'; export { encodeUlid } from './domain/ulid.js'; +export { isHostedLlm, withoutTrailingSlashes } from './domain/llmHost.js'; export { mimeForPath } from './domain/mime.js'; +export type { PreKind, PublishedVersion, Version } from './domain/version.js'; +export { chooseUpdateTarget, compareVersions, parseVersion } from './domain/version.js'; + export { MIGRATIONS, SCHEMA_VERSION, pendingMigrations } from './db/schema.js'; export { importRecording, importPath } from './pipelines/import.js'; @@ -94,6 +101,8 @@ export { EMBEDDING_MODEL, DEFAULT_MODEL_NAME, findModel, + findModelFile, + RETIRED_MODELS, } from './provision/catalogue.js'; export type { Action, PlanOptions } from './provision/plan.js'; @@ -116,3 +125,22 @@ export { } from './summarize/templates.js'; export type { SummaryTemplate } from './summarize/templates.js'; export type { SummaryRequest, SummarySource } from './summarize/prompt.js'; + +export type { ProgressEvent, OnProgress } from './progress/events.js'; +export type { StageWeight } from './progress/scale.js'; +export { + clampMonotonic, + multilingualStages, + singlePassStages, + stageScale, + weightedOverall, +} from './progress/scale.js'; + +export type { LanguageGuess } from './transcribe/languageGuess.js'; +export { guessLanguages } from './transcribe/languageGuess.js'; + +export { DENOISE_MODES, NOISY_SNR_DB, shouldDenoise, snrDb } from './audio/noise.js'; +export type { DenoiseMode } from './audio/noise.js'; + +export { DEFAULT_MAX_CPU_PERCENT, resourceBudget } from './resources/budget.js'; +export type { CpuTopology, ResourceBudget } from './resources/budget.js'; diff --git a/packages/core/src/pipelines/transcribe.test.ts b/packages/core/src/pipelines/transcribe.test.ts index 61678b2..de50e46 100644 --- a/packages/core/src/pipelines/transcribe.test.ts +++ b/packages/core/src/pipelines/transcribe.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { RawSegment, Recording } from '../domain/model.js'; import type { Diarizer, SpeechSpan, TranscriptionProvider } from '../domain/ports.js'; +import type { ProgressEvent } from '../progress/events.js'; import { FakeAudioTool, FakeClock, @@ -26,18 +27,28 @@ const recording: Recording = { importedAt: '2026-01-01T00:00:00.000Z', }; -const deps = () => ({ +/** + * @param progressFractions Forwarded to FakeStt so a test can make the + * provider drive a caller's onProgress closure. Empty by default, matching + * every pre-existing use of deps() where the provider never calls it. + */ +const deps = (progressFractions: readonly number[] = []) => ({ fs: new MemFs({ '/data/media/sh/sha-AUDIO.mp3': 'AUDIO' }), store: new InMemoryStore(), audio: new FakeAudioTool(), - stt: new FakeStt({ - language: 'ru', - model: 'base.bin', - segments: [ - { startMs: 0, endMs: 1500, text: 'Privet.' }, - { startMs: 1500, endMs: 3200, text: 'Kak dela?' }, - ], - }), + stt: new FakeStt( + { + language: 'ru', + model: 'base.bin', + segments: [ + { startMs: 0, endMs: 1500, text: 'Privet.' }, + { startMs: 1500, endMs: 3200, text: 'Kak dela?' }, + ], + }, + undefined, + [], + progressFractions, + ), clock: new FakeClock(), ids: new FakeIds(), mediaRoot: '/data/media', @@ -272,6 +283,12 @@ interface MultilingualScenario { readonly languages?: readonly string[]; readonly texts?: ReadonlyArray; readonly supportsLanguageDetection?: boolean; + /** + * Forwarded to FakeStt so a test can make the provider drive a caller's + * onProgress closure on every transcribe() call it makes (one per run). + * Empty by default, matching every pre-existing scenario. + */ + readonly progressFractions?: readonly number[]; } /** Deps for the multilingual path: a segmenter, a queue of detected languages, and one transcribe result per run. */ @@ -295,6 +312,7 @@ function multilingualDeps(scenario: MultilingualScenario) { results, { supportsLanguageDetection: scenario.supportsLanguageDetection ?? true }, languages, + scenario.progressFractions ?? [], ), segmenter: new FakeSegmenter(spans), clock: new FakeClock(), @@ -503,3 +521,436 @@ describe('transcribeRecording --multilingual', () => { expect(segments.map((s) => s.speaker)).toEqual(['speaker_00', 'speaker_01']); }); }); + +describe('transcribeRecording progress', () => { + it('reports progress that rises to one on the single-pass path', async () => { + const events: ProgressEvent[] = []; + await transcribeRecording( + { ...deps(), onProgress: (event) => events.push(event) }, + recording, + {}, + ); + expect(events.length).toBeGreaterThan(0); + expect(events.map((e) => e.stage)).toContain('transcribing'); + const fractions = events.flatMap((e) => (e.fraction === undefined ? [] : [e.fraction])); + expect(Math.max(...fractions)).toBe(1); + }); + + it('never reports a fraction that went backwards', async () => { + const events: ProgressEvent[] = []; + const d = multilingualDeps({ + spans: [ + { startMs: 0, endMs: 1750 }, + { startMs: 1800, endMs: 3430 }, + ], + languages: ['en', 'ru'], + }); + await transcribeRecording({ ...d, onProgress: (event) => events.push(event) }, recording, { + multilingual: true, + declaredLanguages: ['en', 'ru'], + }); + const fractions = events.flatMap((e) => (e.fraction === undefined ? [] : [e.fraction])); + for (const [i, fraction] of fractions.entries()) { + if (i > 0) expect(fraction).toBeGreaterThanOrEqual(fractions[i - 1]!); + } + }); + + it('reports the diarizing stage while it cannot be measured, then again once done', async () => { + // Exactly two 'diarizing' events, in this order: the first announces the + // stage starting and deliberately carries no fraction (the diarizer + // reports nothing about its own progress), and the second is the run's + // closing report, which lands on 'diarizing' precisely so the bar + // reaches 100% on a diarized run -- see stageScale's doc comment. Pinning + // both count and order (not just "some"/"every") is what actually + // encodes the honesty rule: [1, undefined] would satisfy a looser check + // but would mean the close arrived before the start, or a second + // measured value was invented mid-run. + const events: ProgressEvent[] = []; + const diarizer = new FakeDiarizer([{ startMs: 0, endMs: 3200, speaker: 'speaker_00' }]); + await transcribeRecording( + { ...deps(), diarizer, onProgress: (event) => events.push(event) }, + recording, + { diarize: true }, + ); + const diarizing = events.filter((e) => e.stage === 'diarizing'); + expect(diarizing.map((e) => e.fraction)).toEqual([undefined, 1]); + }); + + it( + 'multilingual stage names carry increasing fractions -- segmenting, then ' + + 'detecting, then labelling at 1 when diarize is on', + async () => { + // stageScale returns 0 for a stage name it does not recognise, and + // nothing throws when that happens -- a typo in one of these three + // string literals (each also hardcoded in transcribe.ts) would stall + // the bar silently through that whole phase with no test failing. + // This pins the literal names by asserting the shape only a CORRECTLY + // named stage can produce. + const events: ProgressEvent[] = []; + const d = multilingualDeps({ + spans: [ + { startMs: 0, endMs: 1750 }, + { startMs: 1800, endMs: 3430 }, + ], + languages: ['en', 'ru'], + }); + // Two speaker turns, one per language span: with --diarize on, the + // turns become the detection units instead of the segmenter's spans + // (see transcribeMultilingual's own comment), so this is what makes + // units.length 2 rather than 1. + const diarizer = new FakeDiarizer([ + { startMs: 0, endMs: 1775, speaker: 'speaker_00' }, + { startMs: 1775, endMs: 3430, speaker: 'speaker_01' }, + ]); + await transcribeRecording( + { ...d, diarizer, onProgress: (event) => events.push(event) }, + recording, + { multilingual: true, declaredLanguages: ['en', 'ru'], diarize: true }, + ); + + const segmenting = events.filter((e) => e.stage === 'segmenting'); + const detecting = events.filter((e) => e.stage === 'detecting'); + expect(segmenting.length).toBeGreaterThan(0); + // Two detection units (one per span above): strictly increasing, not + // merely non-decreasing, is what proves each unit actually advanced + // the bar rather than reporting the same number twice. + expect(detecting.length).toBe(2); + const segmentingEnd = segmenting.at(-1)!.fraction!; + expect(detecting[0]!.fraction!).toBeGreaterThan(segmentingEnd); + expect(detecting[1]!.fraction!).toBeGreaterThan(detecting[0]!.fraction!); + + // The run's closing report, with --diarize on, lands on 'labelling' at + // exactly 1 -- the multilingual sibling of the single-pass + // 'diarizing'/1 pair pinned in the test above. + expect(events.at(-1)).toEqual({ stage: 'labelling', fraction: 1 }); + }, + ); + + it('still produces a transcript when the progress sink throws', async () => { + const transcript = await transcribeRecording( + { + ...deps(), + onProgress: () => { + throw new Error('sink exploded'); + }, + }, + recording, + {}, + ); + expect(transcript.id).toBeDefined(); + }); + + it('still produces a transcript when no progress sink is supplied at all', async () => { + const transcript = await transcribeRecording(deps(), recording, {}); + expect(transcript.id).toBeDefined(); + }); + + it("scales the provider's own progress into the transcribing stage on the single-pass path", async () => { + // Unlike the other single-pass tests above, this one actually drives the + // onProgress closure passed to deps.stt.transcribe -- the thing the + // provider itself calls -- rather than only the direct report() calls + // transcribeRecording makes on its own. FakeStt(..., [0.5, 1]) reports + // those two fractions synchronously from inside transcribe(). + const events: ProgressEvent[] = []; + await transcribeRecording( + { ...deps([0.5, 1]), onProgress: (event) => events.push(event) }, + recording, + {}, + ); + const transcribing = events.filter((e) => e.stage === 'transcribing'); + const fractions = transcribing.flatMap((e) => (e.fraction === undefined ? [] : [e.fraction])); + // scale('converting', 0), scale('transcribing', 0) precede the provider's + // own two reports, so at least two, and the provider's 1.0 must reach + // the top of the transcribing stage's share -- which is the whole run + // here, since there is no diarizing stage to follow it. + expect(fractions.length).toBeGreaterThanOrEqual(2); + for (const [i, fraction] of fractions.entries()) { + if (i > 0) expect(fraction).toBeGreaterThanOrEqual(fractions[i - 1]!); + } + expect(fractions.at(-1)).toBe(1); + }); + + it( + "weights the provider's progress across multilingual runs by their audio " + + 'duration, never decreasing or exceeding one', + async () => { + // Two runs of unequal length (see the sliced-bounds test above for + // where 1775 -- the merge boundary -- comes from): run one is ~1775ms, + // run two ~1655ms of the 3430ms total. Each run's FakeStt call reports + // [0.5, 1] through the onProgress closure under test -- the one that + // computes (doneRunMs + runMs * fraction) / totalRunMs. If that + // arithmetic weighted by run count instead of audio duration, or reset + // to 0 rather than carrying doneRunMs forward, the run boundary + // (fractions[1] to fractions[2]) would go backwards or jump to + // implausible values instead of stepping forward from run one's own + // share to run two's. + const events: ProgressEvent[] = []; + const d = multilingualDeps({ + spans: [ + { startMs: 0, endMs: 1750 }, + { startMs: 1800, endMs: 3430 }, + ], + languages: ['en', 'ru'], + progressFractions: [0.5, 1], + }); + await transcribeRecording({ ...d, onProgress: (event) => events.push(event) }, recording, { + multilingual: true, + }); + const transcribing = events.filter((e) => e.stage === 'transcribing'); + const fractions = transcribing.flatMap((e) => (e.fraction === undefined ? [] : [e.fraction])); + // Two runs x two reported fractions each, at minimum (the closing + // report may add one more, also 'transcribing' since diarize is off). + expect(fractions.length).toBeGreaterThanOrEqual(4); + for (const fraction of fractions) { + expect(fraction).toBeLessThanOrEqual(1); + } + for (const [i, fraction] of fractions.entries()) { + if (i > 0) expect(fraction).toBeGreaterThanOrEqual(fractions[i - 1]!); + } + // The run boundary: run two's first (0.5-of-its-own-share) report must + // not fall below run one's last (1.0-of-its-own-share, i.e. run one + // fully done) report -- proof the weighting carries doneRunMs forward + // rather than resetting per run. + expect(fractions[2]!).toBeGreaterThanOrEqual(fractions[1]!); + expect(fractions.at(-1)).toBe(1); + }, + ); + + it('still produces a transcript when the sink invoked from inside the single-pass provider throws', async () => { + // The other "sink throws" test above only proves transcribeRecording's + // own direct report() calls are safe. This one proves the guarantee + // holds for the closure the provider itself calls mid-transcribe -- + // FakeStt(..., [0.5, 1]) actually invokes it, from inside transcribe(), + // rather than leaving it unused like every deps() call before this task. + const transcript = await transcribeRecording( + { + ...deps([0.5, 1]), + onProgress: () => { + throw new Error('sink exploded from inside the provider'); + }, + }, + recording, + {}, + ); + expect(transcript.id).toBeDefined(); + }); + + it('still produces a transcript when the sink invoked from inside a multilingual run throws', async () => { + // The highest-risk closure in the multilingual path: it closes over + // doneRunMs, runMs and totalRunMs, runs inside the run loop's try/finally + // that removes each temporary slice, and is invoked by the provider, not + // by transcribeRecording directly. A throw here must neither abort the + // run nor skip the slice cleanup in that finally. + const d = multilingualDeps({ + spans: [ + { startMs: 0, endMs: 1750 }, + { startMs: 1800, endMs: 3430 }, + ], + languages: ['en', 'ru'], + progressFractions: [0.5, 1], + }); + const filesBefore = d.fs.files.size; + const transcript = await transcribeRecording( + { + ...d, + onProgress: () => { + throw new Error('sink exploded from inside the provider'); + }, + }, + recording, + { multilingual: true }, + ); + expect(transcript.id).toBeDefined(); + // The throw did not skip a slice's finally-cleanup either. + expect(d.fs.files.size).toBeLessThanOrEqual(filesBefore); + }); + + it('still produces a transcript when the sink returns a rejected promise', async () => { + // TranscribeDeps.onProgress is typed `() => void`, but TypeScript assigns + // an async function to a void-returning type without complaint (see the + // report() doc comment), so this is reachable despite the type. report()'s + // synchronous try/catch cannot see a rejection that arrives after the + // call already returned -- only report()'s explicit thenable guard does. + // Without that guard this rejection would be unhandled: vitest fails a + // run on an unhandled rejection, which is what makes the guard's absence + // observable here rather than merely theoretical. + const transcript = await transcribeRecording( + { + ...deps(), + onProgress: async () => { + throw new Error('sink rejected asynchronously'); + }, + }, + recording, + {}, + ); + expect(transcript.id).toBeDefined(); + }); +}); + +describe('denoising', () => { + it('passes the mode it was given to the converter', async () => { + const audio = new FakeAudioTool(); + await transcribeRecording({ ...deps(), audio }, recording, { denoise: 'auto' }); + expect(audio.denoiseModes).toEqual(['auto']); + }); + + it('passes nothing when no mode was given', async () => { + // Library callers and every pre-existing test keep the old behaviour. + const audio = new FakeAudioTool(); + await transcribeRecording({ ...deps(), audio }, recording, {}); + expect(audio.denoiseModes).toEqual([undefined]); + }); + + it('notices that denoising was not measured, only requested, when the mode is "on"', async () => { + // "on" skips the measurement entirely (see ffmpeg.ts), so the profile is + // two nulls -- reporting that as "no measurable noise floor" would claim + // a measurement that never ran. + const notices: string[] = []; + const audio = new FakeAudioTool(); + audio.prepared = { denoised: true, profile: { rmsDb: null, noiseFloorDb: null } }; + await transcribeRecording( + { ...deps(), audio, onNotice: (message) => notices.push(message) }, + recording, + { denoise: 'on' }, + ); + expect(notices.join('\n')).toMatch(/not measured, denoising was requested/); + }); + + it('notices that denoising was not measured, only skipped, when the mode is "off"', async () => { + const notices: string[] = []; + const audio = new FakeAudioTool(); + await transcribeRecording( + { ...deps(), audio, onNotice: (message) => notices.push(message) }, + recording, + { denoise: 'off' }, + ); + expect(notices.join('\n')).toMatch(/not measured, denoising is off/); + }); + + it('notices that it left the audio alone, without warning about it', async () => { + // The job log gets the decision in both directions; the terminal only + // hears about it when the audio was actually altered. "I left your audio + // alone" is not worth a line on every run. + const notices: string[] = []; + const warnings: string[] = []; + const audio = new FakeAudioTool(); + await transcribeRecording( + { + ...deps(), + audio, + onNotice: (message) => notices.push(message), + onWarning: (message) => warnings.push(message), + }, + recording, + { denoise: 'auto' }, + ); + expect(notices.join('\n')).toMatch(/not denoised/i); + expect(warnings).toEqual([]); + }); + + it('warns as well as notices when the audio was altered', async () => { + const notices: string[] = []; + const warnings: string[] = []; + const audio = new FakeAudioTool(); + // The fake reports no denoising by default; override for this one case. + audio.prepared = { denoised: true, profile: { rmsDb: -22.18, noiseFloorDb: -38.53 } }; + await transcribeRecording( + { + ...deps(), + audio, + onNotice: (message) => notices.push(message), + onWarning: (message) => warnings.push(message), + }, + recording, + { denoise: 'auto' }, + ); + expect(warnings.join('\n')).toMatch(/denoised/i); + // The numbers travel with the decision, so a reader can judge it. + expect(notices.join('\n')).toContain('16.4'); + }); + + it('does not fail a transcription when the notice sink throws', async () => { + // Same guarantee onProgress already has: an observer does not get to fail + // a transcription. + const audio = new FakeAudioTool(); + await expect( + transcribeRecording( + { + ...deps(), + audio, + onNotice: () => { + throw new Error('sink exploded'); + }, + }, + recording, + { denoise: 'auto' }, + ), + ).resolves.toBeDefined(); + }); + + // The multilingual path hand-duplicates the same notice/warning block + // (transcribe.ts's transcribeMultilingual) rather than sharing a helper + // with the single-pass path above -- see that function's own comment. + // Duplicated code with assertions on only one copy is exactly where the + // copies drift, so this mirrors every case above against + // { multilingual: true } instead of {}. + it('passes the mode it was given to the converter on the multilingual path', async () => { + const d = multilingualDeps({}); + await transcribeRecording(d, recording, { multilingual: true, denoise: 'auto' }); + expect(d.audio.denoiseModes).toEqual(['auto']); + }); + + it('notices that it left the audio alone on the multilingual path, without warning about it', async () => { + const notices: string[] = []; + const warnings: string[] = []; + const d = multilingualDeps({}); + await transcribeRecording( + { + ...d, + onNotice: (message) => notices.push(message), + onWarning: (message) => warnings.push(message), + }, + recording, + { multilingual: true, denoise: 'auto' }, + ); + expect(notices.join('\n')).toMatch(/not denoised/i); + expect(warnings).toEqual([]); + }); + + it('warns as well as notices when the audio was altered on the multilingual path', async () => { + const notices: string[] = []; + const warnings: string[] = []; + const d = multilingualDeps({}); + // The fake reports no denoising by default; override for this one case. + d.audio.prepared = { denoised: true, profile: { rmsDb: -22.18, noiseFloorDb: -38.53 } }; + await transcribeRecording( + { + ...d, + onNotice: (message) => notices.push(message), + onWarning: (message) => warnings.push(message), + }, + recording, + { multilingual: true, denoise: 'auto' }, + ); + expect(warnings.join('\n')).toMatch(/denoised/i); + // The numbers travel with the decision, so a reader can judge it. + expect(notices.join('\n')).toContain('16.4'); + }); + + it('does not fail a multilingual transcription when the notice sink throws', async () => { + const d = multilingualDeps({}); + await expect( + transcribeRecording( + { + ...d, + onNotice: () => { + throw new Error('sink exploded'); + }, + }, + recording, + { multilingual: true, denoise: 'auto' }, + ), + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/core/src/pipelines/transcribe.ts b/packages/core/src/pipelines/transcribe.ts index f07e407..3109397 100644 --- a/packages/core/src/pipelines/transcribe.ts +++ b/packages/core/src/pipelines/transcribe.ts @@ -11,8 +11,13 @@ import type { TranscriptionProvider, } from '../domain/ports.js'; import type { RawSegment, Recording, Segment, Transcript } from '../domain/model.js'; +import type { WavPrepared } from '../domain/ports.js'; import { FailureError } from '../domain/errors.js'; import { assignSpeakers } from '../diarize/assign.js'; +import type { DenoiseMode } from '../audio/noise.js'; +import { snrDb } from '../audio/noise.js'; +import type { OnProgress } from '../progress/events.js'; +import { multilingualStages, singlePassStages, stageScale } from '../progress/scale.js'; import { detectionWindowMs, mergeRuns, @@ -41,6 +46,24 @@ export interface TranscribeDeps { * unset, such problems are simply not reported. */ readonly onWarning?: (message: string) => void; + /** + * Routine facts worth recording but not worth interrupting anyone with. + * + * Distinct from `onWarning`, which reaches the terminal: the CLI wires this + * to the job log only. A denoising decision is a routine decision, and a + * foreground run has no log, so it correctly prints nothing. + */ + readonly onNotice?: (message: string) => void; + /** + * Reports how far along the run is. Supplied by the caller for the same + * reason `onWarning` is: core does no I/O and does not know whether this + * becomes a spinner, a file, or nothing. + * + * Every call goes through `report` below, which swallows whatever this + * throws. A progress observer that could abort a transcription would be + * strictly worse than no progress at all. + */ + readonly onProgress?: OnProgress; } export interface TranscribeOptions { @@ -75,6 +98,87 @@ export interface TranscribeOptions { readonly diarize?: true; /** Hint for the diarizer: the known number of speakers, when known. */ readonly speakers?: number; + /** + * Whether to denoise the converted audio. Absent means no measurement and + * no filtering, which is what every caller predating this option expects. + */ + readonly denoise?: DenoiseMode; +} + +/** + * Emits one progress event, and cannot fail. + * + * The try is the whole point of the function existing. Every emitter in this + * file goes through it, so "a progress sink cannot break a transcription" is + * true by structure rather than by everyone remembering to wrap their call. + * It absorbs both a synchronous throw and, via the guard below, a rejected + * promise from a sink that ignored `OnProgress`'s "should be synchronous". + */ +function report(deps: TranscribeDeps, stage: string, fraction?: number): void { + try { + const returned: unknown = deps.onProgress?.({ + stage, + ...(fraction === undefined ? {} : { fraction }), + }); + // `OnProgress` returns void, but TypeScript assigns `() => Promise` + // to `() => void` without complaint, and this project does not enable + // no-misused-promises. So an async sink is reachable, and its rejection + // would surface as an unhandled rejection -- which on Node can end the + // process in the middle of an hour of transcription. The synchronous + // catch below cannot see that, so the thenable is swallowed here. + if ( + typeof returned === 'object' && + returned !== null && + typeof (returned as { readonly then?: unknown }).then === 'function' + ) { + void (returned as Promise).catch(() => { + // Same reason as the catch below. Deliberately empty. + }); + } + } catch { + // See the doc comment. Deliberately empty. + } +} + +/** + * Emits one job-log notice, and cannot fail. + * + * Same guarantee `report` gives, and for the same reason: an observer does + * not get to fail a transcription. + */ +function notice(deps: TranscribeDeps, message: string): void { + try { + deps.onNotice?.(message); + } catch { + // Same guarantee report() gives: an observer does not get to fail a + // transcription. + } +} + +/** + * One line describing what the conversion did about noise, with the numbers + * that decided it -- an agent reading a job log has to be able to tell that + * the audio was altered, or that it deliberately was not. + * + * `mode` decides how to read a null SNR: `on` and `off` never measure (see + * ffmpeg.ts's `toWav16kMono`), so their profile is always two nulls, and + * reporting that as "no measurable noise floor" would claim a measurement + * that never happened. Only `auto` actually measures, so only there does a + * null SNR mean the measurement ran and found nothing. + */ +function denoiseMessage(prepared: WavPrepared, mode: DenoiseMode): string { + const snr = snrDb(prepared.profile); + const measured = + mode === 'on' + ? 'not measured, denoising was requested' + : mode === 'off' + ? 'not measured, denoising is off' + : snr === null + ? 'no measurable noise floor' + : `snr ${snr.toFixed(1)} dB`; + return prepared.denoised + ? `audio denoised before transcription (${measured})` + : `audio not denoised (${measured})`; } /** @@ -234,18 +338,37 @@ export async function transcribeRecording( return transcribeMultilingual(deps, recording, options); } + const scale = stageScale(singlePassStages(options.diarize === true)); const tempWav = await deps.fs.tempFile('.wav'); try { - await deps.audio.toWav16kMono(`${deps.mediaRoot}/${recording.mediaPath}`, tempWav.path); + report(deps, 'converting', scale('converting', 0)); + const prepared = await deps.audio.toWav16kMono( + `${deps.mediaRoot}/${recording.mediaPath}`, + tempWav.path, + options.denoise === undefined ? undefined : { denoise: options.denoise }, + ); + if (options.denoise !== undefined) { + notice(deps, denoiseMessage(prepared, options.denoise)); + // The terminal hears about it only when the audio actually changed: the + // transcript no longer comes from the file the user imported, and that + // is worth one line. + if (prepared.denoised) deps.onWarning?.(denoiseMessage(prepared, options.denoise)); + } + report(deps, 'transcribing', scale('transcribing', 0)); const result = await deps.stt.transcribe(tempWav.path, { ...(options.language === undefined ? {} : { language: options.language }), ...(options.model === undefined ? {} : { model: options.model }), + onProgress: (fraction) => report(deps, 'transcribing', scale('transcribing', fraction)), }); if (result.segments.length === 0) { throw new FailureError(`${deps.stt.name} found no speech in ${recording.sourcePath}`); } + // No fraction: the diarizer reports nothing about its own progress, and + // a number invented here would be indistinguishable from a measured one. + if (options.diarize === true) report(deps, 'diarizing'); + // One diarizer pass over the whole recording, on the same full-recording // wav the transcript just came from -- before tempWav.remove() runs in // this try's finally. @@ -263,7 +386,12 @@ export async function transcribeRecording( const segments = buildSegments(deps, transcript.id, withSpeakerLabels); + // Nothing progress-related between the assembled transcript and its + // write to the store -- that boundary stays exactly as bare as it was + // before this feature existed. The closing report lands right after, + // once the transcript this run exists to produce is already durable. await deps.store.insertTranscript(transcript, segments); + report(deps, options.diarize === true ? 'diarizing' : 'transcribing', 1); return transcript; } finally { await tempWav.remove(); @@ -326,7 +454,22 @@ async function transcribeMultilingual( const tempWav = await deps.fs.tempFile('.wav'); try { - await deps.audio.toWav16kMono(`${deps.mediaRoot}/${recording.mediaPath}`, tempWav.path); + // The scale cannot be built until the units are known -- their count is + // one of its weights. Until then, report stages without a fraction. + report(deps, 'converting'); + const prepared = await deps.audio.toWav16kMono( + `${deps.mediaRoot}/${recording.mediaPath}`, + tempWav.path, + options.denoise === undefined ? undefined : { denoise: options.denoise }, + ); + if (options.denoise !== undefined) { + notice(deps, denoiseMessage(prepared, options.denoise)); + // The terminal hears about it only when the audio actually changed: the + // transcript no longer comes from the file the user imported, and that + // is worth one line. + if (prepared.denoised) deps.onWarning?.(denoiseMessage(prepared, options.denoise)); + } + report(deps, 'segmenting'); const declared = options.declaredLanguages ?? []; @@ -349,6 +492,16 @@ async function transcribeMultilingual( detectionWindowMs(declared.length), ); + const audioSeconds = recording.durationMs / 1000; + const scale = stageScale( + multilingualStages({ + unitCount: units.length, + audioSeconds, + diarize: options.diarize === true, + }), + ); + report(deps, 'segmenting', scale('segmenting', 1)); + const detected: (DetectedSpan & { speaker?: string })[] = []; for (const unit of units) { const slice = await deps.fs.tempFile('.wav'); @@ -358,6 +511,7 @@ async function transcribeMultilingual( ...(options.model === undefined ? {} : { model: options.model }), }); detected.push({ ...unit, language }); + report(deps, 'detecting', scale('detecting', detected.length / Math.max(1, units.length))); } finally { await slice.remove(); } @@ -383,13 +537,27 @@ async function transcribeMultilingual( } const outcomes: RunOutcome[] = []; + const totalRunMs = runs.reduce((sum, run) => sum + (run.endMs - run.startMs), 0); + let doneRunMs = 0; for (const run of runs) { + const runMs = run.endMs - run.startMs; const slice = await deps.fs.tempFile('.wav'); try { await deps.audio.slice(tempWav.path, slice.path, run.startMs, run.endMs); const result = await deps.stt.transcribe(slice.path, { language: run.language, ...(options.model === undefined ? {} : { model: options.model }), + // Weighted by audio, not by run: runs differ in length by an order + // of magnitude, and counting them makes a bar that crawls then jumps. + onProgress: (fraction) => + report( + deps, + 'transcribing', + scale( + 'transcribing', + totalRunMs <= 0 ? 0 : (doneRunMs + runMs * fraction) / totalRunMs, + ), + ), }); outcomes.push({ run, @@ -406,6 +574,7 @@ async function transcribeMultilingual( language: run.language, })), }); + doneRunMs += runMs; } finally { await slice.remove(); } @@ -416,6 +585,13 @@ async function transcribeMultilingual( throw new FailureError(`${deps.stt.name} found no speech in ${recording.sourcePath}`); } + // No fraction: the diarizer reports nothing about its own progress, and + // a number invented here would be indistinguishable from a measured one. + // Announced here, immediately before the labelling work itself, and + // deliberately not next to the closing report below -- moving it there + // would announce the stage only after it already finished. + if (options.diarize === true) report(deps, 'labelling'); + // Every run's segments are already shifted onto the recording's absolute // timeline (see the comment above), so one diarizer pass over the whole // wav -- not one per run -- lines up with all of them at once. @@ -451,7 +627,11 @@ async function transcribeMultilingual( const segments = buildSegments(deps, transcript.id, withSpeakerLabels); + // Nothing progress-related between the assembled transcript and its + // write to the store, for the same reason the single-pass path holds + // that boundary bare. The closing report lands right after. await deps.store.insertTranscript(transcript, segments); + report(deps, options.diarize === true ? 'labelling' : 'transcribing', 1); return transcript; } finally { await tempWav.remove(); diff --git a/packages/core/src/progress/events.ts b/packages/core/src/progress/events.ts new file mode 100644 index 0000000..51a473c --- /dev/null +++ b/packages/core/src/progress/events.ts @@ -0,0 +1,29 @@ +/** + * Where a long-running pipeline has got to. + * + * `fraction` is omitted for a stage whose progress cannot be measured -- a + * diarizer pass reports its name and nothing else, because inventing a + * number for it would be a lie the caller cannot detect. See honesty rule 2 + * in the design. + */ +export interface ProgressEvent { + readonly stage: string; + /** 0..1 across the whole run, not within the stage. Absent when unmeasurable. */ + readonly fraction?: number; +} + +/** + * Where progress goes. Core does no I/O, so a caller supplies this and + * decides whether it becomes a spinner, a file, or nothing at all -- the + * same arrangement `TranscribeDeps.onWarning` already uses. + * + * An implementation must not throw, and should be synchronous. Callers guard + * it anyway, because the rule that a progress failure never costs a + * transcription has to hold by structure rather than by trust: TypeScript + * assigns an `async` function to this `void`-returning type without + * complaint, so nothing here stops a sink from returning a promise. If one + * does, its rejection is swallowed rather than left to surface as an + * unhandled rejection -- a deliberate belt-and-braces measure against a + * hazard the type alone does not rule out. + */ +export type OnProgress = (event: ProgressEvent) => void; diff --git a/packages/core/src/progress/scale.test.ts b/packages/core/src/progress/scale.test.ts new file mode 100644 index 0000000..72a0561 --- /dev/null +++ b/packages/core/src/progress/scale.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + clampMonotonic, + multilingualStages, + singlePassStages, + stageScale, + weightedOverall, +} from './scale.js'; + +describe('stageScale', () => { + const scale = stageScale([ + { name: 'a', weight: 2 }, + { name: 'b', weight: 8 }, + ]); + + it('places the start of the first stage at zero', () => { + expect(scale('a', 0)).toBe(0); + }); + + it('places the end of the last stage at one', () => { + expect(scale('b', 1)).toBe(1); + }); + + it('offsets a stage by the weight of everything before it', () => { + expect(scale('b', 0)).toBeCloseTo(0.2); + expect(scale('b', 0.5)).toBeCloseTo(0.6); + }); + + it('normalises across whatever weights it was given', () => { + const uneven = stageScale([ + { name: 'x', weight: 1 }, + { name: 'y', weight: 3 }, + ]); + expect(uneven('y', 0)).toBeCloseTo(0.25); + }); + + it('clamps a within-stage fraction outside 0..1', () => { + expect(scale('a', -1)).toBe(0); + expect(scale('a', 5)).toBeCloseTo(0.2); + }); + + it('returns 0 for a stage it does not know, rather than throwing', () => { + expect(scale('nope', 0.5)).toBe(0); + }); + + it('returns 0 for an empty stage list, rather than dividing by zero', () => { + expect(stageScale([])('a', 0.5)).toBe(0); + }); +}); + +describe('clampMonotonic', () => { + it('takes the larger of the two', () => { + expect(clampMonotonic(0.4, 0.6)).toBe(0.6); + }); + + it('refuses to go backwards', () => { + expect(clampMonotonic(0.6, 0.4)).toBe(0.6); + }); + + it('bounds the result to 0..1', () => { + expect(clampMonotonic(0, -1)).toBe(0); + expect(clampMonotonic(0, 5)).toBe(1); + }); + + it('treats a non-finite next value as no news', () => { + expect(clampMonotonic(0.5, Number.NaN)).toBe(0.5); + }); +}); + +describe('weightedOverall', () => { + it('weights by duration, not by count', () => { + // Four short recordings and one long one: finishing the four is not 80%. + const durations = [1000, 1000, 1000, 1000, 16_000]; + expect(weightedOverall(durations, 4, 0)).toBeCloseTo(0.2); + }); + + it('interpolates within the current recording', () => { + expect(weightedOverall([1000, 1000], 0, 0.5)).toBeCloseTo(0.25); + }); + + it('reaches one at the end of the last recording', () => { + expect(weightedOverall([1000, 3000], 1, 1)).toBe(1); + }); + + it('falls back to counting when no duration is known', () => { + expect(weightedOverall([0, 0], 1, 0)).toBeCloseTo(0.5); + }); + + it('returns 0 for an empty list rather than dividing by zero', () => { + expect(weightedOverall([], 0, 0.5)).toBe(0); + }); + + it('ignores an out-of-range index rather than throwing', () => { + expect(weightedOverall([1000], 7, 0.5)).toBe(1); + }); +}); + +describe('singlePassStages', () => { + it('leaves no gap when diarization is off', () => { + const stages = singlePassStages(false); + expect(stages.map((s) => s.name)).toEqual(['converting', 'transcribing']); + expect(stageScale(stages)('transcribing', 1)).toBe(1); + }); + + it('reserves a slice for diarization when it is on', () => { + const stages = singlePassStages(true); + expect(stages.map((s) => s.name)).toEqual(['converting', 'transcribing', 'diarizing']); + expect(stageScale(stages)('transcribing', 1)).toBeCloseTo(0.92); + }); +}); + +describe('multilingualStages', () => { + it('grows the detection stage with the number of units', () => { + const few = multilingualStages({ unitCount: 4, audioSeconds: 600, diarize: false }); + const many = multilingualStages({ unitCount: 90, audioSeconds: 600, diarize: false }); + const weightOf = (stages: readonly { name: string; weight: number }[], name: string) => + stages.find((s) => s.name === name)?.weight ?? 0; + expect(weightOf(many, 'detecting')).toBeGreaterThan(weightOf(few, 'detecting')); + }); + + it('gives detection the larger share when detections outnumber the audio', () => { + // 40 model loads against 60 seconds of audio: detection dominates, and a + // fixed 20/70 split would park the bar at 20% for most of the run. + const stages = multilingualStages({ unitCount: 40, audioSeconds: 60, diarize: false }); + const scale = stageScale(stages); + expect(scale('transcribing', 0) - scale('detecting', 0)).toBeGreaterThan(0.5); + }); + + it('never gives a stage a zero weight, so no stage is unreachable', () => { + const stages = multilingualStages({ unitCount: 0, audioSeconds: 0, diarize: true }); + for (const stage of stages) expect(stage.weight).toBeGreaterThan(0); + }); + + it('ends on transcribing when diarization is off', () => { + const stages = multilingualStages({ unitCount: 4, audioSeconds: 600, diarize: false }); + expect(stageScale(stages)('transcribing', 1)).toBe(1); + }); + + it('ends on labelling when diarization is on, because labels come after the words', () => { + // withSpeakers runs after the transcription loop in transcribeMultilingual: + // segments must exist before a speaker can be attributed to them. So + // `transcribing` finishing is NOT the run finishing. + const stages = multilingualStages({ unitCount: 4, audioSeconds: 600, diarize: true }); + const scale = stageScale(stages); + expect(scale('labelling', 1)).toBe(1); + expect(scale('transcribing', 1)).toBeLessThan(1); + }); + + it('weights converting for two ffmpeg passes plus a scan', () => { + // Denoising can add a measurement and a re-encode to the conversion stage. + // No new stage is introduced for them: a stage that is present but never + // reported strands the bar, per singlePassStages' own comment. + expect(singlePassStages(false)[0]).toEqual({ name: 'converting', weight: 4 }); + expect(multilingualStages({ unitCount: 4, audioSeconds: 100, diarize: false })[0]).toEqual({ + name: 'converting', + weight: 4, + }); + }); +}); diff --git a/packages/core/src/progress/scale.ts b/packages/core/src/progress/scale.ts new file mode 100644 index 0000000..eea0c54 --- /dev/null +++ b/packages/core/src/progress/scale.ts @@ -0,0 +1,145 @@ +/** One stage of a run, and how much of the whole it is worth. */ +export interface StageWeight { + readonly name: string; + readonly weight: number; +} + +function bounded(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +/** + * Maps "half way through the detection stage" onto "23% of the run". + * + * Weights are normalised by their sum rather than required to add to 100, so + * a caller can hand over whatever units the work is naturally measured in -- + * see multilingualStages, where detection is counted in model loads and + * transcription in seconds of audio. + * + * An unknown stage name answers 0 rather than throwing. This function sits + * on the progress path, and the progress path may not fail a transcription. + */ +export function stageScale( + stages: readonly StageWeight[], +): (name: string, within: number) => number { + const total = stages.reduce((sum, stage) => sum + Math.max(0, stage.weight), 0); + const offsets = new Map(); + let running = 0; + for (const stage of stages) { + const weight = Math.max(0, stage.weight); + offsets.set(stage.name, { offset: running, weight }); + running += weight; + } + return (name, within) => { + if (total <= 0) return 0; + const found = offsets.get(name); + if (found === undefined) return 0; + return bounded((found.offset + found.weight * bounded(within)) / total); + }; +} + +/** + * The larger of the two, bounded to 0..1. + * + * A bar that goes backwards is worse than a bar that stalls: the reader + * stops believing the number. The multilingual path can genuinely produce a + * lower fraction than it last reported -- a run merged differently than the + * detection pass suggested -- and this is where that is absorbed. + * + * A non-finite `next` is treated as no news rather than as zero, so a + * division that went wrong upstream cannot reset the bar. + */ +export function clampMonotonic(previous: number, next: number): number { + const floor = bounded(previous); + if (!Number.isFinite(next)) return floor; + return Math.max(floor, bounded(next)); +} + +/** + * How far through a batch of recordings, weighted by their durations. + * + * Counting recordings instead would report 60% at "3 of 5" when the fifth is + * longer than the first four together, which is the single most misleading + * number this feature could produce. Durations come from `Recording. + * durationMs`, which import already stores. + * + * Falls back to counting when every duration is zero or missing, which is + * the only honest thing left to do. + */ +export function weightedOverall( + durationsMs: readonly number[], + index: number, + within: number, +): number { + if (durationsMs.length === 0) return 0; + const safe = durationsMs.map((ms) => (Number.isFinite(ms) && ms > 0 ? ms : 0)); + const total = safe.reduce((sum, ms) => sum + ms, 0); + if (total <= 0) return bounded((Math.min(index, durationsMs.length) + 0) / durationsMs.length); + if (index >= safe.length) return 1; + const done = safe.slice(0, index).reduce((sum, ms) => sum + ms, 0); + return bounded((done + (safe[index] ?? 0) * bounded(within)) / total); +} + +/** + * The single-language path: one whisper call over the whole file. + * + * whisper is nearly all of it, and its own reported percentage fills that + * stage. The conversion is a couple of seconds of ffmpeg. Diarization is + * omitted entirely when it is off, rather than left in at its full weight of + * 8: a stage that is present but never reports progress is a stage the bar + * can never move through, and leaving diarizing's 8 in unused would strand + * the bar at 92% forever. (Leaving it in at weight zero would not stall it -- + * see multilingualStages below for what a zero-weight stage does instead.) + * + * Four rather than two since denoising: the stage can now hold a conversion, + * a noise scan and a re-encode. No new stage was added for them -- a stage + * that is present but never reported is exactly how the bar gets stranded, + * per the note below. + */ +export function singlePassStages(diarize: boolean): StageWeight[] { + return [ + { name: 'converting', weight: 4 }, + { name: 'transcribing', weight: 90 }, + ...(diarize ? [{ name: 'diarizing', weight: 8 }] : []), + ]; +} + +/** + * The multilingual path, where the split between detection and transcription + * cannot be hardcoded. + * + * Every detection unit is a SEPARATE whisper process, and detectLanguage's + * own comment says it "costs about the same as a short transcription because + * almost all of it is loading the model". So detection is counted in model + * loads and transcription in tenths of its audio (whisper runs roughly ten + * times faster than real time), and stageScale normalises the two against + * each other. A forty-unit recording therefore does not sit at 20% for most + * of the run, which a fixed split would have produced. + * + * Both computed weights have a floor of 1. Without it, a stage with weight 0 + * that turns out to be the LAST one in the list would make the bar jump to + * 100% the moment that stage's name is first reported, not once its work is + * actually done: stageScale gives a terminal stage's offset the full total + * already, so weight 0 there contributes nothing further and the fraction + * reads as complete immediately. The floor keeps every stage genuinely worth + * one unit of the bar, at the cost of overweighting it slightly on a very + * short recording. + */ +export function multilingualStages(input: { + readonly unitCount: number; + readonly audioSeconds: number; + readonly diarize: boolean; +}): StageWeight[] { + const detecting = Math.max(1, Math.round(input.unitCount)); + const transcribing = Math.max(1, Math.round(input.audioSeconds / 10)); + return [ + { name: 'converting', weight: 4 }, + { name: 'segmenting', weight: 8 }, + { name: 'detecting', weight: detecting }, + { name: 'transcribing', weight: transcribing }, + ...(input.diarize ? [{ name: 'labelling', weight: 2 }] : []), + ]; +} diff --git a/packages/core/src/provision/catalogue.test.ts b/packages/core/src/provision/catalogue.test.ts new file mode 100644 index 0000000..173fd0e --- /dev/null +++ b/packages/core/src/provision/catalogue.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_MODEL_NAME, + findModel, + findModelFile, + RETIRED_MODELS, + TRANSCRIPTION_MODELS, +} from './catalogue.js'; + +/** + * The retirement contract. Two models were removed from what `setup` offers + * because measurement showed each is dominated by something smaller, but they + * are still resolvable -- and the difference between "not offered" and "not + * resolvable" is exactly what keeps an existing installation working. + */ +describe('the transcription catalogue', () => { + it('offers the default it names', () => { + expect(TRANSCRIPTION_MODELS.map((model) => model.name)).toContain(DEFAULT_MODEL_NAME); + }); + + it('does not offer a retired model', () => { + const offered = TRANSCRIPTION_MODELS.map((model) => model.name); + for (const retired of RETIRED_MODELS) { + expect(offered).not.toContain(retired.name); + } + }); + + it('still resolves a retired model by name, so --model medium keeps working', () => { + expect(findModel('medium')?.file).toBe('ggml-medium.bin'); + expect(findModel('large-v3-turbo')?.file).toBe('ggml-large-v3-turbo.bin'); + }); + + it('still resolves a retired model by file, so an installed one is recognised', () => { + // The anti-silent-switch guarantee: answering undefined here would make a + // healthy installed model look unrecognised, and `setup --force` would + // replace it with the default. + expect(findModelFile('ggml-medium.bin')?.name).toBe('medium'); + }); + + it('resolves offered models too, by either key', () => { + expect(findModel(DEFAULT_MODEL_NAME)?.name).toBe(DEFAULT_MODEL_NAME); + expect(findModelFile('ggml-tiny.bin')?.name).toBe('tiny'); + }); + + it('answers undefined for a name and a file it has never heard of', () => { + expect(findModel('enormous-v9')).toBeUndefined(); + expect(findModelFile('ggml-enormous-v9.bin')).toBeUndefined(); + }); + + it('has no name or file in both lists', () => { + // A duplicate would make findModel's answer depend on which list it + // searched first, which is not a thing a reader should have to know. + const offeredNames = TRANSCRIPTION_MODELS.map((model) => model.name); + const offeredFiles = TRANSCRIPTION_MODELS.map((model) => model.file); + for (const retired of RETIRED_MODELS) { + expect(offeredNames).not.toContain(retired.name); + expect(offeredFiles).not.toContain(retired.file); + } + }); + + it('gives every entry, offered or retired, a url ending in its own file', () => { + for (const model of [...TRANSCRIPTION_MODELS, ...RETIRED_MODELS]) { + expect(model.url.endsWith(model.file)).toBe(true); + expect(model.bytes).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/provision/catalogue.ts b/packages/core/src/provision/catalogue.ts index 9fcf4c0..da58ba6 100644 --- a/packages/core/src/provision/catalogue.ts +++ b/packages/core/src/provision/catalogue.ts @@ -55,21 +55,97 @@ export const TRANSCRIPTION_MODELS: readonly ModelChoice[] = [ file: 'ggml-small.bin', url: `${HF_WHISPER}/ggml-small.bin`, bytes: 487_601_967, - summary: 'the default -- what multilingual mode was tuned against', + summary: 'lighter -- what multilingual mode was tuned against', }, + { + /** + * The default. A 5-bit quantisation of large-v3-turbo, chosen over both + * `small` (which it replaced) and its own f16 build. + * + * MEASURED 2026-09-08 on three corpora -- a 24 kbit/s Russian conference + * recording, FLEURS ru_ru, LibriSpeech test-clean -- with word error rates + * compared by a bootstrap over clips: + * + * Russian read speech `small` 7.5% this 2.1% + * Russian conversation `small` 32.0% this 23.6% + * Russian at 10 dB SNR `small` 12.6% this 3.5% + * English read speech `small` 2.4% this 1.6% + * + * The quantisation is what makes it affordable. Against the f16 build of + * the same model the difference is statistically indistinguishable on all + * three corpora, while this file is a third the size. It matters most + * where there is no GPU: on eight CPU threads this decodes at 0.449 times + * real time against `small`'s 0.451 and f16 turbo's 0.846, because 5-bit + * weights halve the memory traffic and memory bandwidth is what limits + * CPU decoding. So the better model costs nothing at all on a CPU-only + * machine, and 1.7x `small`'s decode time on a GPU. + * + * Do not "upgrade" this entry to the f16 build. That trades 1.1 GB of + * download and twice the CPU decode time for an accuracy difference no + * measurement here could separate from zero. + */ + name: 'large-v3-turbo-q5_0', + file: 'ggml-large-v3-turbo-q5_0.bin', + url: `${HF_WHISPER}/ggml-large-v3-turbo-q5_0.bin`, + bytes: 574_041_195, + summary: 'the default -- most accurate for its size', + }, + { + /** + * The deliberate maximum. Measurably better than the default only on hard + * audio -- about 2 points on the supplied conference recording -- and + * indistinguishable from it on clean Russian, on spontaneous Russian and + * on far-field meeting audio, where it was in fact 2 points WORSE. It also + * decodes at 0.203 against the default's 0.112, so it is the right answer + * for a difficult recording somebody cares about and the wrong one for a + * library. + */ + name: 'large-v3', + file: 'ggml-large-v3.bin', + url: `${HF_WHISPER}/ggml-large-v3.bin`, + bytes: 3_095_033_483, + summary: 'heaviest -- a little better on hard audio', + }, +]; + +/** + * Models an earlier version offered and this one does not. + * + * MEASURED 2026-09-08 (see the default's comment above for the corpora): each + * is dominated by something smaller. `medium` is larger, slower AND less + * accurate than large-v3-turbo on every corpus tried; `large-v3-turbo` in f16 + * is 2.8x the default's download and, without a GPU, 1.9x its decode time, + * for an accuracy difference no comparison could separate from zero. + * + * Still resolvable rather than deleted, for two reasons that both bite + * existing installations: + * + * - `ailoud setup --model medium` keeps working. Somebody's script says + * that, and the model itself is fine -- it is merely a poor choice. + * - `findModelFile` still recognises an installed one AS itself. Without + * that, `setup --force` on a machine running `medium` would see a + * stranger's file where its own catalogue name should be, fall through to + * DEFAULT_MODEL_NAME, and silently replace a healthy model the user chose + * on purpose. That exact silent switch was found and fixed once already. + * + * They are absent from TRANSCRIPTION_MODELS, so the interactive picker and + * the "choose one of" message offer only the list above. Nothing here should + * be recommended to anyone. + */ +export const RETIRED_MODELS: readonly ModelChoice[] = [ { name: 'medium', file: 'ggml-medium.bin', url: `${HF_WHISPER}/ggml-medium.bin`, bytes: 1_533_763_059, - summary: 'slower, more accurate', + summary: 'retired -- large-v3-turbo-q5_0 is smaller, faster and better', }, { name: 'large-v3-turbo', file: 'ggml-large-v3-turbo.bin', url: `${HF_WHISPER}/ggml-large-v3-turbo.bin`, bytes: 1_624_555_275, - summary: 'most accurate, heaviest', + summary: 'retired -- the q5_0 build of it is a third the size, and no worse', }, ]; @@ -125,10 +201,43 @@ export const EMBEDDING_MODEL: ModelChoice = { summary: 'speaker embedding, needed by --diarize', }; -export const DEFAULT_MODEL_NAME = 'small'; +/** + * What `setup` installs when nobody says otherwise. See the entry's own + * comment above for the measurements that chose it over `small`. + * + * An existing installation is never migrated by this constant: a healthy + * configured model is left alone, and `resolveModelName` prefers whatever is + * already installed over this default precisely so that changing it here + * cannot silently replace a model someone chose on purpose. + */ +export const DEFAULT_MODEL_NAME = 'large-v3-turbo-q5_0'; +/** + * A model by catalogue name, retired ones included. + * + * Resolving covers more than offering: a name this returns is one `--model` + * accepts and `setup` can install. The offered list is TRANSCRIPTION_MODELS, + * and only that list belongs in a picker or a "choose one of" message. + */ export function findModel(name: string): ModelChoice | undefined { - return TRANSCRIPTION_MODELS.find((model) => model.name === name); + return ( + TRANSCRIPTION_MODELS.find((model) => model.name === name) ?? + RETIRED_MODELS.find((model) => model.name === name) + ); +} + +/** + * A model by the file name it is stored under, retired ones included. + * + * This is how an installed model is recognised as itself. Answering + * `undefined` for a model that is merely no longer offered would make + * `setup --force` treat a healthy install as unrecognised and replace it. + */ +export function findModelFile(file: string): ModelChoice | undefined { + return ( + TRANSCRIPTION_MODELS.find((model) => model.file === file) ?? + RETIRED_MODELS.find((model) => model.file === file) + ); } /** diff --git a/packages/core/src/resources/budget.test.ts b/packages/core/src/resources/budget.test.ts new file mode 100644 index 0000000..56b1ab4 --- /dev/null +++ b/packages/core/src/resources/budget.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_MAX_CPU_PERCENT, resourceBudget } from './budget.js'; + +describe('resourceBudget', () => { + it.each([ + // logical, performance, percent, threads, cappedThreads + [10, 8, 90, 7, 6], + [10, 8, 100, 8, 6], + [10, 8, 50, 4, 4], + [10, 8, 10, 1, 1], + [10, null, 90, 9, 6], + [16, null, 90, 14, 6], + [64, null, 90, 58, 6], + [2, null, 100, 2, 2], + [1, null, 90, 1, 1], + [4, null, 0, 4, 4], + ])( + 'gives %i logical / %s performance at %i%% -> %i threads, %i for the capped engines', + (logical, performance, maxCpuPercent, threads, cappedThreads) => { + const budget = resourceBudget({ logical, performance }, { maxCpuPercent }); + expect(budget.threads).toBe(threads); + expect(budget.cappedThreads).toBe(cappedThreads); + }, + ); + + it('never lets the diarizer or the VAD reach the thread count measured as catastrophic', () => { + // The whole reason cappedThreads exists. On this project's reference + // machine (8 performance cores) both the diarizer and the VAD segmenter + // are slower at 8 threads than at 1; at 6 each is fastest. No percent may + // produce the bad number. + for (let percent = 1; percent <= 100; percent += 1) { + const budget = resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: percent }); + expect(budget.cappedThreads).toBeLessThanOrEqual(6); + expect(budget.cappedThreads).toBeGreaterThanOrEqual(1); + } + }); + + it('never lets cappedThreads exceed 6, on any plausible machine shape', () => { + // The defect this locks against: an earlier revision capped cappedThreads + // relative to the machine's size (`base - 2`), which stopped binding at + // all above ~16 logical cores -- exactly the shape most non-Apple-Silicon + // servers have, since `performance` is null everywhere except darwin. The + // table-driven test above only ever swept one topology (10/8), which is + // exactly why that defect survived review. + const logicalCounts = [1, 2, 4, 8, 10, 16, 32, 64, 128]; + for (const logical of logicalCounts) { + for (const performance of [null, Math.max(1, Math.round(logical * 0.75))]) { + for (let percent = 1; percent <= 100; percent += 1) { + const budget = resourceBudget({ logical, performance }, { maxCpuPercent: percent }); + expect(budget.cappedThreads).toBeLessThanOrEqual(6); + expect(budget.cappedThreads).toBeGreaterThanOrEqual(1); + } + } + } + }); + + it('prefers performance cores over logical ones when the split is known', () => { + // Efficiency cores make whisper.cpp slower, not faster: every layer waits + // for its slowest thread. + expect(resourceBudget({ logical: 10, performance: 8 }, { maxCpuPercent: 100 }).threads).toBe(8); + }); + + it('defaults to 90 percent with no options at all', () => { + expect(resourceBudget({ logical: 10, performance: 8 })).toEqual({ + threads: 7, + cappedThreads: 6, + gpu: true, + }); + }); + + it.each([0, 101, -5, Number.NaN, Number.POSITIVE_INFINITY])( + 'falls back to the default percent rather than throwing on %s', + (maxCpuPercent) => { + const fallback = resourceBudget({ logical: 8, performance: null }); + expect(resourceBudget({ logical: 8, performance: null }, { maxCpuPercent })).toEqual( + fallback, + ); + expect(DEFAULT_MAX_CPU_PERCENT).toBe(90); + }, + ); + + it('never answers fewer than one thread, whatever it is given', () => { + // A zero-thread flag would make every engine refuse to start, which is + // the one outcome a resource hint must never cause. + expect(resourceBudget({ logical: 0, performance: 0 }, { maxCpuPercent: 1 }).threads).toBe(1); + expect(resourceBudget({ logical: 0, performance: 0 }, { maxCpuPercent: 1 }).cappedThreads).toBe( + 1, + ); + }); + + it('carries the gpu flag through, defaulting to on', () => { + expect(resourceBudget({ logical: 8, performance: null }).gpu).toBe(true); + expect(resourceBudget({ logical: 8, performance: null }, { gpu: false }).gpu).toBe(false); + }); +}); diff --git a/packages/core/src/resources/budget.ts b/packages/core/src/resources/budget.ts new file mode 100644 index 0000000..997657c --- /dev/null +++ b/packages/core/src/resources/budget.ts @@ -0,0 +1,125 @@ +/** What this machine offers, as far as it can be read. */ +export interface CpuTopology { + /** Every logical CPU the process may run on. At least 1. */ + readonly logical: number; + /** + * Performance cores, or null when the platform does not report the split. + * Only Apple Silicon reports it today; see providers/system/cpuTopology.ts + * for why Linux is deliberately not guessed at. + */ + readonly performance: number | null; +} + +/** How much of the machine each engine may take. */ +export interface ResourceBudget { + /** The ceiling: what an engine that scales with threads may use. */ + readonly threads: number; + /** + * The share handed to engines with a measured optimum below the ceiling, + * capped below it: the speaker diarizer and the VAD speech segmenter. Not + * named after either one, on purpose -- both were measured to the same + * optimum, by different mechanisms, and a field named after only one of + * them would invite the next reader to hand the other engine the full + * ceiling. + * + * MEASURED on 607 s of speech, sherpa-onnx diarizer, 8 performance cores: + * + * 1 thread 120.0 s 6 threads 45.2 s <- fastest + * 2 threads 72.6 s 7 threads 56.5 s + * 4 threads 50.3 s 8 threads 66-122 s + * 10 threads 105.6 s + * + * The binary holds two ONNX sessions, each with its own intra-op pool, so N + * threads per pass oversubscribes a machine with N performance cores. Handing + * this engine the full ceiling would have made diarization slower than it was + * before this feature existed. + * + * MEASURED on 607 s of speech, whisper-vad-speech-segments, same machine: + * + * 1 thread 5.62 s 6 threads 2.17 s <- fastest + * 2 threads 3.38 s 7 threads 3.01 s + * 4 threads 2.38 s 8 threads 3.88 s + * + * A different mechanism reaches the same optimum: this binary runs one + * small model, where thread coordination overhead dominates past a handful + * of threads rather than two ONNX sessions competing for cores. Same + * number, different reason -- which is exactly why this field is not named + * after either engine. + */ + readonly cappedThreads: number; + /** False means: pass the engine's disable-GPU flag, where one exists. */ + readonly gpu: boolean; +} + +export const DEFAULT_MAX_CPU_PERCENT = 90; + +/** + * The absolute ceiling for the capped engines, regardless of machine size. + * + * An earlier revision capped `cappedThreads` relative to the base + * (`max(1, base - 2)`) instead of with this absolute number. That was a wrong + * generalisation from one machine, and it failed in both directions: + * + * MEASURED on 607 s of speech, sherpa-onnx diarizer, 8 performance cores: + * + * 1 thread 120.0 s 6 threads 45.2 s <- fastest + * 2 threads 72.6 s 7 threads 56.5 s + * 4 threads 50.3 s 8 threads 66-122 s + * 10 threads 105.6 s + * + * MEASURED on 607 s of speech, whisper-vad-speech-segments, same machine: + * + * 1 thread 5.62 s 6 threads 2.17 s <- fastest + * 2 threads 3.38 s 7 threads 3.01 s + * 4 threads 2.38 s 8 threads 3.88 s + * + * Both curves bottom out at 6 and climb on both sides of it. `base - 2` only + * ever produced 6 on this one 8-performance-core machine by coincidence: on a + * 16-core machine it does not bind at all (14), and on a 64-core Linux server + * it would have handed an engine measured fastest at 6 a full 58 threads -- + * worse than the 4 both engines defaulted to before this feature existed, so + * a regression rather than a missed optimisation. Below eight cores it bound + * too hard, handing a 2-core machine one thread where two is measurably + * faster (3.38 s against 5.62 s). + * + * 6 is the only optimum either engine has ever measured, measured + * independently for both, by two different mechanisms (oversubscribed ONNX + * sessions for the diarizer, thread-coordination overhead for the VAD). + * Raising this number is a measurement, not a judgement: neither engine has + * been profiled above eight threads on any machine but this one. + */ +const CAPPED_MAX_THREADS = 6; + +function clamp(value: number, low: number, high: number): number { + if (value < low) return low; + if (value > high) return high; + return value; +} + +/** + * Turns a percentage into a thread count per engine. + * + * An out-of-range or non-finite percent falls back to the default instead of + * throwing: validating user input belongs to the CLI, and this function sits + * on the transcription path, where a resource hint may never be the thing + * that fails a run. + */ +export function resourceBudget( + topology: CpuTopology, + options: { readonly maxCpuPercent?: number; readonly gpu?: boolean } = {}, +): ResourceBudget { + const requested = options.maxCpuPercent; + const percent = + requested !== undefined && Number.isFinite(requested) && requested >= 1 && requested <= 100 + ? requested + : DEFAULT_MAX_CPU_PERCENT; + + // `performance` wins where it is known, because efficiency cores make + // whisper.cpp slower rather than faster. Both counts are floored at 1: a + // zero-thread flag would make every engine refuse to start. + const base = Math.max(1, Math.round(topology.performance ?? topology.logical)); + const threads = clamp(Math.round((base * percent) / 100), 1, base); + const cappedThreads = Math.min(threads, CAPPED_MAX_THREADS); + + return { threads, cappedThreads, gpu: options.gpu ?? true }; +} diff --git a/packages/core/src/testing/fakes.ts b/packages/core/src/testing/fakes.ts index 07e5631..3d25ebc 100644 --- a/packages/core/src/testing/fakes.ts +++ b/packages/core/src/testing/fakes.ts @@ -13,7 +13,9 @@ import type { TempDir, TempFile, TranscriptionProvider, + WavPrepared, } from '../domain/ports.js'; +import type { DenoiseMode } from '../audio/noise.js'; import type { RawSegment, Recording, @@ -117,12 +119,27 @@ export class MemFs implements Fs { throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }); return content; } + async rename(from: string, to: string): Promise { + const content = this.files.get(from); + if (content === undefined) + throw Object.assign(new Error(`ENOENT: ${from}`), { code: 'ENOENT' }); + this.files.set(to, content); + this.files.delete(from); + } } export class FakeAudioTool implements AudioTool { readonly converted: Array<[string, string]> = []; /** Every slice() call this fake was given, in call order. */ readonly sliced: Array<{ input: string; output: string; startMs: number; endMs: number }> = []; + /** Every denoise mode this fake was asked for, in call order. */ + readonly denoiseModes: Array = []; + /** + * What toWav16kMono answers. Defaults to "measured nothing, changed + * nothing", so a test that does not care about denoising exercises the + * common path. + */ + prepared: WavPrepared = { denoised: false, profile: { noiseFloorDb: null, rmsDb: null } }; constructor( private readonly durationMs = 60_000, @@ -138,8 +155,14 @@ export class FakeAudioTool implements AudioTool { async probe(): Promise<{ durationMs: number; recordedAt: string | null }> { return { durationMs: this.durationMs, recordedAt: this.recordedAt }; } - async toWav16kMono(input: string, output: string): Promise { + async toWav16kMono( + input: string, + output: string, + opts?: { readonly denoise?: DenoiseMode }, + ): Promise { this.converted.push([input, output]); + this.denoiseModes.push(opts?.denoise); + return this.prepared; } async slice(input: string, output: string, startMs: number, endMs: number): Promise { this.sliced.push({ input, output, startMs, endMs }); @@ -153,7 +176,11 @@ export class FakeStt implements TranscriptionProvider { readonly name = 'fake'; readonly capabilities: TranscriptionProvider['capabilities']; /** Every opts object this fake was called with, in call order. */ - readonly calls: Array<{ readonly language?: string; readonly model?: string }> = []; + readonly calls: Array<{ + readonly language?: string; + readonly model?: string; + readonly onProgress?: (fraction: number) => void; + }> = []; /** Every audio path handed to transcribe(), in call order. */ readonly transcribePaths: string[] = []; /** Every audio path handed to detectLanguage(), in call order. */ @@ -167,6 +194,7 @@ export class FakeStt implements TranscriptionProvider { segments: RawSegment[]; }>; private readonly languageQueue: string[]; + private readonly progressFractions: readonly number[]; constructor( result: @@ -174,9 +202,19 @@ export class FakeStt implements TranscriptionProvider { | ReadonlyArray<{ language: string; model: string; segments: RawSegment[] }>, capabilities?: Partial, detectedLanguages: readonly string[] = [], + /** + * Fractions transcribe() reports through opts.onProgress, in order, on + * every call it makes. Empty (the default) is the original behaviour: + * the fake never calls onProgress, same as a real provider that cannot + * report progress. A test that needs to drive a caller's onProgress + * closure -- rather than just supply one that is never invoked -- passes + * a sequence here, e.g. [0.5, 1]. + */ + progressFractions: readonly number[] = [], ) { this.results = Array.isArray(result) ? result : [result]; this.languageQueue = [...detectedLanguages]; + this.progressFractions = progressFractions; this.capabilities = { maxBytes: null, supportsDiarization: false, @@ -188,12 +226,22 @@ export class FakeStt implements TranscriptionProvider { async transcribe( audioPath: string, - opts: { readonly language?: string; readonly model?: string }, + opts: { + readonly language?: string; + readonly model?: string; + readonly onProgress?: (fraction: number) => void; + }, ): Promise<{ language: string; model: string; segments: RawSegment[] }> { this.transcribePaths.push(audioPath); const result = this.results[this.calls.length] ?? this.results.at(-1); this.calls.push(opts); if (result === undefined) throw new Error('FakeStt has no canned result to return'); + // Mirrors a real provider: reports its own progress synchronously, + // before the call resolves, and does not guard the callback itself -- + // that guarantee belongs to the caller (see report() in transcribe.ts). + for (const fraction of this.progressFractions) { + opts.onProgress?.(fraction); + } return result; } diff --git a/packages/core/src/transcribe/languageGuess.test.ts b/packages/core/src/transcribe/languageGuess.test.ts new file mode 100644 index 0000000..253008e --- /dev/null +++ b/packages/core/src/transcribe/languageGuess.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { guessLanguages } from './languageGuess.js'; + +describe('guessLanguages', () => { + it('reads codes out of a filename', () => { + const guess = guessLanguages({ sourcePath: '/in/2026-08-14-standup-ru-en.m4a' }); + expect(guess?.languages).toEqual(['ru', 'en']); + expect(guess?.from).toContain('2026-08-14-standup-ru-en.m4a'); + }); + + it('reads language names as well as codes', () => { + expect(guessLanguages({ sourcePath: '/in/interview-russian.wav' })?.languages).toEqual(['ru']); + }); + + it('infers ru from Cyrillic in the name', () => { + const guess = guessLanguages({ + // Escaped, not literal: source stays ASCII (AGENTS.md, Text and Encoding). + sourcePath: '/in/sozvon-\u0441-\u043a\u043e\u043c\u0430\u043d\u0434\u043e\u0439.m4a', + }); + expect(guess?.languages).toEqual(['ru']); + }); + + it('reads the title when the filename says nothing', () => { + const guess = guessLanguages({ sourcePath: '/in/rec0007.wav', title: 'German kickoff call' }); + expect(guess?.languages).toEqual(['de']); + }); + + it('reads tags too', () => { + const guess = guessLanguages({ sourcePath: '/in/rec0007.wav', tags: ['lang-en', 'backend'] }); + expect(guess?.languages).toEqual(['en']); + }); + + it('keeps the order it found them in and does not repeat one', () => { + const guess = guessLanguages({ sourcePath: '/in/en-ru-en-call.wav' }); + expect(guess?.languages).toEqual(['en', 'ru']); + }); + + it('returns null when nothing in the name suggests a language', () => { + expect(guessLanguages({ sourcePath: '/in/rec0007.wav' })).toBeNull(); + }); + + it('does not read a language out of an unrelated word that contains a code', () => { + // "standup" contains "an"; "rendered" contains "en". Tokens are matched + // whole, never as substrings, or every filename would guess something. + expect(guessLanguages({ sourcePath: '/in/standup-rendered.wav' })).toBeNull(); + }); + + it('ignores the extension, which is not a language', () => { + expect(guessLanguages({ sourcePath: '/in/meeting.is' })).toBeNull(); + }); + + it('survives a path with no basename at all', () => { + expect(guessLanguages({ sourcePath: '/' })).toBeNull(); + }); +}); diff --git a/packages/core/src/transcribe/languageGuess.ts b/packages/core/src/transcribe/languageGuess.ts new file mode 100644 index 0000000..457db32 --- /dev/null +++ b/packages/core/src/transcribe/languageGuess.ts @@ -0,0 +1,100 @@ +/** A guess and where it came from, so the guess can be shown with its evidence. */ +export interface LanguageGuess { + readonly languages: readonly string[]; + /** Human phrasing of the evidence, e.g. `filename "standup-ru-en.m4a"`. */ + readonly from: string; +} + +/** + * Language names worth recognising, mapped to their codes. + * + * Deliberately short. This is a hint offered to a human for confirmation, + * not a language identification library, and every entry added is another + * chance to guess confidently wrong. English names only: the interface is + * English-only, and a name in its own language would be caught by the + * script check below anyway for the cases that matter. + */ +const NAMES: Readonly> = { + english: 'en', + russian: 'ru', + german: 'de', + french: 'fr', + spanish: 'es', + italian: 'it', + polish: 'pl', + portuguese: 'pt', + dutch: 'nl', + turkish: 'tr', + ukrainian: 'uk', + chinese: 'zh', + japanese: 'ja', + korean: 'ko', + arabic: 'ar', + hindi: 'hi', +}; + +/** + * Codes recognised bare, e.g. the `ru` and `en` in `standup-ru-en.m4a`. + * + * A closed list, not "any two letters": every filename contains two-letter + * tokens, and accepting them all would turn `2026-08-14-q3-review` into a + * confident guess. Kept to the languages whisper is actually good at, plus + * the ones this tool's users record in. + */ +const CODES = new Set(Object.values(NAMES)); + +/** Any Cyrillic letter. A Cyrillic filename is ru far more often than not. */ +const CYRILLIC = /[\u0400-\u04FF]/; + +/** The filename without its directories or its extension. */ +function basename(path: string): string { + const last = path.split('/').pop() ?? ''; + const dot = last.lastIndexOf('.'); + return dot > 0 ? last.slice(0, dot) : last; +} + +/** + * Languages the recording's own name suggests, or null. + * + * Null, never a fabrication. A guess invented from nothing is worse than no + * guess: it reaches a user who is skimming, gets confirmed, and then whisper + * is forced into the wrong language for an hour of audio. The caller shows + * this to a human for confirmation and never acts on it alone. + * + * Tokens are matched whole rather than as substrings, because "standup" + * contains "an" and "rendered" contains "en", and a substring match would + * make almost every filename look multilingual. + */ +export function guessLanguages(input: { + readonly sourcePath: string; + readonly title?: string | null; + readonly tags?: readonly string[]; +}): LanguageGuess | null { + const name = basename(input.sourcePath); + const rawName = input.sourcePath.split('/').pop() ?? ''; + const sources: { readonly text: string; readonly label: string }[] = [ + { text: name, label: `filename "${rawName}"` }, + ...(input.title === undefined || input.title === null || input.title === '' + ? [] + : [{ text: input.title, label: `title "${input.title}"` }]), + ...(input.tags === undefined || input.tags.length === 0 + ? [] + : [{ text: input.tags.join(' '), label: `tags ${input.tags.join(', ')}` }]), + ]; + + const found: string[] = []; + const evidence: string[] = []; + for (const source of sources) { + const before = found.length; + for (const token of source.text.toLowerCase().split(/[^a-z\u0400-\u04FF]+/)) { + if (token === '') continue; + const code = CODES.has(token) ? token : NAMES[token]; + if (code !== undefined && !found.includes(code)) found.push(code); + } + if (CYRILLIC.test(source.text) && !found.includes('ru')) found.push('ru'); + if (found.length > before) evidence.push(source.label); + } + + if (found.length === 0) return null; + return { languages: found, from: evidence.join(', ') }; +} diff --git a/packages/providers/README.md b/packages/providers/README.md new file mode 100644 index 0000000..2cf98c5 --- /dev/null +++ b/packages/providers/README.md @@ -0,0 +1,20 @@ +# @ailoud/providers + +The adapters of [AILoud](https://github.com/lorem-dev/ailoud): each port +declared in [`@ailoud/core`](https://www.npmjs.com/package/@ailoud/core), +implemented against something real. + +| Port | Implemented with | +| ----------------------- | ----------------------------------------------------------- | +| `AudioTool` | ffmpeg and ffprobe | +| `TranscriptionProvider` | whisper.cpp | +| `RecordingStore` | SQLite (`node:sqlite`) with FTS5 search | +| `Summarizer` | llama.cpp, the Claude CLI, or the Anthropic and OpenAI APIs | + +These talk to binaries and files on the machine, so they are covered by the +end-to-end suite rather than by mocks. + +Published because the CLI depends on it. The interfaces are not stable yet, and +there is no reason to depend on this package directly. + +Documentation: diff --git a/packages/providers/package.json b/packages/providers/package.json index 246edc5..6d87362 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -1,6 +1,6 @@ { "name": "@ailoud/providers", - "version": "0.0.0", + "version": "1.2.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -18,6 +18,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "description": "AILoud port implementations: ffmpeg, sqlite, whisper.cpp, and the LLM adapters", + "author": "Lorem Dev ", "license": "Apache-2.0", "homepage": "https://lorem-dev.github.io/ailoud/", "bugs": { diff --git a/packages/providers/src/audio/ffmpeg.test.ts b/packages/providers/src/audio/ffmpeg.test.ts index 0b75a77..7dc573e 100644 --- a/packages/providers/src/audio/ffmpeg.test.ts +++ b/packages/providers/src/audio/ffmpeg.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { run } from '../process/run.js'; import { FfmpegAudioTool } from './ffmpeg.js'; @@ -28,6 +29,22 @@ afterAll(async () => { await rm(dir, { recursive: true, force: true }); }); +/** The first audio stream's sample rate and channel count, as ffprobe sees it. */ +async function audioStreamOf(path: string): Promise<{ sample_rate: string; channels: number }> { + const probe = await run('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'a:0', + '-show_entries', + 'stream=sample_rate,channels', + '-of', + 'json', + path, + ]); + return JSON.parse(probe.stdout).streams[0]; +} + describe('FfmpegAudioTool', () => { it('probes the duration', async () => { const { durationMs } = await new FfmpegAudioTool().probe(source); @@ -38,18 +55,7 @@ describe('FfmpegAudioTool', () => { it('converts to 16 kHz mono wav', async () => { const output = join(dir, 'out.wav'); await new FfmpegAudioTool().toWav16kMono(source, output); - const probe = await run('ffprobe', [ - '-v', - 'error', - '-select_streams', - 'a:0', - '-show_entries', - 'stream=sample_rate,channels', - '-of', - 'json', - output, - ]); - const stream = JSON.parse(probe.stdout).streams[0]; + const stream = await audioStreamOf(output); expect(stream.sample_rate).toBe('16000'); expect(stream.channels).toBe(1); }); @@ -77,3 +83,189 @@ describe('FfmpegAudioTool', () => { await expect(new FfmpegAudioTool().slice(bad, join(dir, 'o.wav'), 0, 1000)).rejects.toThrow(); }); }); + +describe('toWav16kMono denoising', () => { + // Derived from the constructor itself, rather than redeclared, so the mock + // types can never drift from what FfmpegAudioTool actually accepts. + type FfmpegCtorOptions = NonNullable[2]>; + type Runner = NonNullable; + type Renamer = NonNullable; + + function tool(runner: Runner): FfmpegAudioTool { + // The mocked runner never makes a real ffmpeg write the scratch file, so + // rename is stubbed too: this suite is about the decision logic (which + // calls happen, in which order, with what args), not about exercising a + // real filesystem rename. + return new FfmpegAudioTool('ffmpeg', 'ffprobe', { + runner, + rename: vi.fn().mockResolvedValue(undefined), + }); + } + + const CLEAN = + '[Parsed_astats_0 @ 0x1] RMS level dB: -16.17\n[Parsed_astats_0 @ 0x1] Noise floor dB: -43.30'; + const NOISY = + '[Parsed_astats_0 @ 0x1] RMS level dB: -22.16\n[Parsed_astats_0 @ 0x1] Noise floor dB: -38.94'; + + it('does not measure at all when the mode is off', async () => { + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const prepared = await tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'off' }); + expect(runner).toHaveBeenCalledTimes(1); + expect(prepared).toEqual({ denoised: false, profile: { noiseFloorDb: null, rmsDb: null } }); + }); + + it('does not measure at all when no options are given', async () => { + // Keeps every pre-existing caller and test byte-for-byte unchanged in + // behaviour: the feature is opt-in from the wiring, not a silent default + // of the adapter. + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + await tool(runner).toWav16kMono('/in.mp4', '/out.wav'); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it('measures and then leaves clean audio alone in auto mode', async () => { + const runner = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: CLEAN }); + const prepared = await tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'auto' }); + expect(runner).toHaveBeenCalledTimes(2); + expect(prepared.denoised).toBe(false); + expect(prepared.profile).toEqual({ rmsDb: -16.17, noiseFloorDb: -43.3 }); + }); + + it('measures and then filters noisy audio in auto mode', async () => { + const runner = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: NOISY }) + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); + const prepared = await tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'auto' }); + expect(runner).toHaveBeenCalledTimes(3); + expect(prepared.denoised).toBe(true); + // The filter writes a scratch file, never the output directly: ffmpeg + // cannot filter a file in place, and an -i and -y on one path truncates + // the input before reading it. + const filterArgs = runner.mock.calls[2]![1] as string[]; + expect(filterArgs).toContain('/out.wav'); + expect(filterArgs[filterArgs.length - 1]).not.toBe('/out.wav'); + }); + + it('filters without measuring when the mode is on', async () => { + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const prepared = await tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'on' }); + // Convert, then filter. No measurement: "on" is an instruction. + expect(runner).toHaveBeenCalledTimes(2); + expect(prepared.denoised).toBe(true); + }); + + it('keeps the plain conversion when the measurement fails', async () => { + const runner = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) + .mockRejectedValueOnce(new Error('ffmpeg exploded')); + const prepared = await tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'auto' }); + expect(prepared).toEqual({ denoised: false, profile: { noiseFloorDb: null, rmsDb: null } }); + }); + + it('keeps the plain conversion when the filter pass fails', async () => { + // The governing principle: denoising is an optimisation, the transcript is + // the product. A failed filter must leave a usable wav behind, not a + // half-written one and not an exception. + const runner = vi + .fn() + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: NOISY }) + .mockResolvedValueOnce({ code: 1, stdout: '', stderr: 'no such filter' }); + const prepared = await tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'auto' }); + expect(prepared.denoised).toBe(false); + expect(prepared.profile.rmsDb).toBe(-22.16); + }); + + it('still throws when the conversion itself fails', async () => { + // The one failure that IS fatal: without a wav there is nothing to + // transcribe. This must not be swallowed along with the optional steps. + const runner = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'bad input' }); + await expect( + tool(runner).toWav16kMono('/in.mp4', '/out.wav', { denoise: 'auto' }), + ).rejects.toThrow(/could not convert/); + }); +}); + +/** + * The video containers domain/mime.ts knows. `ailoud audio import` accepts + * video because a meeting recording usually is one, and only the audio track + * matters -- so each fixture is the same clip of speech wrapped in a different + * container by scripts/make-fixtures.mjs, and the assertion is that what comes + * out is the 16 kHz mono WAV whisper.cpp needs, whatever went in. + */ +describe.each(['mp4', 'mov', 'mkv', 'webm'])('a %s recording', (container) => { + const fixture = fileURLToPath( + new URL(`../../../../fixtures/en-short.${container}`, import.meta.url), + ); + + it('probes like audio and converts to 16 kHz mono wav', async () => { + const tool = new FfmpegAudioTool(); + const { durationMs } = await tool.probe(fixture); + expect(durationMs).toBeGreaterThan(2000); + expect(durationMs).toBeLessThan(3000); + + const output = join(dir, `${container}.wav`); + await tool.toWav16kMono(fixture, output); + const stream = await audioStreamOf(output); + expect(stream.sample_rate).toBe('16000'); + expect(stream.channels).toBe(1); + }); +}); + +/** + * Denoising against a real ffmpeg, not a mocked runner. + * + * The mocked suite above proves the decision logic -- which calls happen, in + * which order. It cannot prove that the arguments those calls carry actually + * work, and that gap shipped a real defect: `astatsArgs` was missing its + * trailing `-` output target, so ffmpeg refused to run, every measurement came + * back as two nulls, and `auto` silently never denoised anything. Every unit + * test stayed green. These four cases are what would have caught it. + * + * The two fixtures are the anchors the threshold was measured against: + * noisy-short.wav at 16.35 dB SNR must be denoised, en-short.wav at 27.14 dB + * must not. + */ +describe('denoising real audio', () => { + const noisy = fileURLToPath(new URL('../../../../fixtures/noisy-short.wav', import.meta.url)); + const clean = fileURLToPath(new URL('../../../../fixtures/en-short.wav', import.meta.url)); + + it('measures a real file rather than answering nulls', async () => { + // The direct regression test for the missing output target. A profile of + // two nulls here means ffmpeg never produced figures, whatever the reason. + const output = join(dir, 'measured.wav'); + const prepared = await new FfmpegAudioTool().toWav16kMono(clean, output, { denoise: 'auto' }); + expect(prepared.profile.rmsDb).not.toBeNull(); + expect(prepared.profile.noiseFloorDb).not.toBeNull(); + }); + + it('denoises the noisy fixture on auto', async () => { + const output = join(dir, 'auto-noisy.wav'); + const prepared = await new FfmpegAudioTool().toWav16kMono(noisy, output, { denoise: 'auto' }); + expect(prepared.denoised).toBe(true); + // Still the shape whisper is fed, after the filter pass and the rename. + const stream = await audioStreamOf(output); + expect(stream.sample_rate).toBe('16000'); + expect(stream.channels).toBe(1); + }); + + it('leaves the clean fixture alone on auto', async () => { + const output = join(dir, 'auto-clean.wav'); + const prepared = await new FfmpegAudioTool().toWav16kMono(clean, output, { denoise: 'auto' }); + expect(prepared.denoised).toBe(false); + }); + + it('leaves no scratch file behind', async () => { + // applyDenoise writes `.dn.wav` and renames it over the target. + // A leftover scratch file means the rename did not happen. + const output = join(dir, 'scratch.wav'); + await new FfmpegAudioTool().toWav16kMono(noisy, output, { denoise: 'on' }); + await expect(stat(`${output}.dn.wav`)).rejects.toThrow(); + }); +}); diff --git a/packages/providers/src/audio/ffmpeg.ts b/packages/providers/src/audio/ffmpeg.ts index 023ec06..f478a7c 100644 --- a/packages/providers/src/audio/ffmpeg.ts +++ b/packages/providers/src/audio/ffmpeg.ts @@ -1,6 +1,9 @@ +import { rm, rename } from 'node:fs/promises'; import type { AudioTool } from '@ailoud/core'; -import { FailureError, normalizeRecordedAt } from '@ailoud/core'; +import { FailureError, normalizeRecordedAt, shouldDenoise } from '@ailoud/core'; +import type { DenoiseMode, NoiseProfile, WavPrepared } from '@ailoud/core'; import { run } from '../process/run.js'; +import { astatsArgs, denoiseArgs, EMPTY_PROFILE, parseNoiseProfile } from './noise.js'; // Re-encoding audio re-reads the input and writes a new output. The time // depends on audio length, which is the user's, not ours. A long recording @@ -9,16 +12,26 @@ import { run } from '../process/run.js'; const ENCODE_TIMEOUT_MS = 30 * 60 * 1000; export class FfmpegAudioTool implements AudioTool { + private readonly runner: typeof run; + // Injectable for the same reason as runner: applyDenoise's rename is a real + // fs call, and a test driving it through a mocked runner never actually + // makes ffmpeg write the scratch file that rename would need to find. + private readonly renameFile: typeof rename; + constructor( private readonly ffmpeg = 'ffmpeg', private readonly ffprobe = 'ffprobe', - ) {} + options: { readonly runner?: typeof run; readonly rename?: typeof rename } = {}, + ) { + this.runner = options.runner ?? run; + this.renameFile = options.rename ?? rename; + } async probe(path: string): Promise<{ durationMs: number; recordedAt: string | null }> { // Reading container metadata should be fast. A tight timeout here signals // a real problem like a corrupt file or network issue, which is exactly // what a timeout is for. - const result = await run( + const result = await this.runner( this.ffprobe, [ '-v', @@ -52,15 +65,82 @@ export class FfmpegAudioTool implements AudioTool { }; } - async toWav16kMono(input: string, output: string): Promise { - const result = await run( + /** + * A scan, not an encode: it reads the file and writes nothing, so it gets + * the probe timeout rather than the encode one. Measured at roughly a + * thousand times real time. + */ + private async measure(wavPath: string): Promise { + try { + const result = await this.runner(this.ffmpeg, astatsArgs(wavPath), { + timeoutMs: 60_000, + }); + // The exit code is not checked: astats prints its figures on stderr and + // `-f null` makes ffmpeg's own status incidental. A parse that finds + // nothing already answers "no measurement". + return parseNoiseProfile(`${result.stdout}\n${result.stderr}`); + } catch { + return EMPTY_PROFILE; + } + } + + /** + * Rewrites `wavPath` through the filter chain, via a scratch file. + * + * ffmpeg cannot filter a file in place -- `-i x -y x` truncates the input + * before reading it -- so the filtered audio lands beside the target and is + * renamed over it. The rename is what makes this atomic from a reader's + * point of view: either the plain conversion or the cleaned one, never a + * half-written file. + * + * Returns false rather than throwing on any failure, and removes the + * scratch file. A failed optimisation must leave the plain conversion + * behind, which is still perfectly transcribable. + */ + private async applyDenoise(wavPath: string): Promise { + const scratch = `${wavPath}.dn.wav`; + try { + const result = await this.runner(this.ffmpeg, denoiseArgs(wavPath, scratch), { + timeoutMs: ENCODE_TIMEOUT_MS, + }); + if (result.code !== 0) { + await rm(scratch, { force: true }); + return false; + } + await this.renameFile(scratch, wavPath); + return true; + } catch { + await rm(scratch, { force: true }).catch(() => {}); + return false; + } + } + + async toWav16kMono( + input: string, + output: string, + opts: { readonly denoise?: DenoiseMode } = {}, + ): Promise { + const result = await this.runner( this.ffmpeg, ['-v', 'error', '-y', '-i', input, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', output], { timeoutMs: ENCODE_TIMEOUT_MS }, ); + // The one failure here that is genuinely fatal: without a wav there is + // nothing to transcribe. Everything below it is optional and swallows its + // own failures. if (result.code !== 0) { throw new FailureError(`ffmpeg could not convert ${input}: ${result.stderr.trim()}`); } + + const mode = opts.denoise ?? 'off'; + if (mode === 'off') return { denoised: false, profile: EMPTY_PROFILE }; + + // "on" is an instruction, so it skips the measurement entirely rather + // than measuring and then ignoring the answer. + const profile = mode === 'on' ? EMPTY_PROFILE : await this.measure(output); + if (!shouldDenoise(mode, profile)) return { denoised: false, profile }; + + return { denoised: await this.applyDenoise(output), profile }; } async slice(input: string, output: string, startMs: number, endMs: number): Promise { @@ -70,7 +150,7 @@ export class FfmpegAudioTool implements AudioTool { // midpoint the merge step calculated. const start = (startMs / 1000).toFixed(3); const duration = ((endMs - startMs) / 1000).toFixed(3); - const result = await run( + const result = await this.runner( this.ffmpeg, [ '-v', diff --git a/packages/providers/src/audio/noise.test.ts b/packages/providers/src/audio/noise.test.ts new file mode 100644 index 0000000..007e2b2 --- /dev/null +++ b/packages/providers/src/audio/noise.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { astatsArgs, denoiseArgs, EMPTY_PROFILE, parseNoiseProfile } from './noise.js'; + +/** Verbatim from a real run against fixtures/en-short.wav. */ +const REAL_OUTPUT = [ + '[Parsed_astats_0 @ 0x9348009c0] RMS level dB: -16.167788', + '[Parsed_astats_0 @ 0x9348009c0] Noise floor dB: -43.303773', +].join('\n'); + +/** Verbatim from a real run against fixtures/mixed-short.wav. */ +const INF_OUTPUT = [ + '[Parsed_astats_0 @ 0xc85008fc0] RMS level dB: -15.377982', + '[Parsed_astats_0 @ 0xc85008fc0] Noise floor dB: -inf', +].join('\n'); + +describe('parseNoiseProfile', () => { + it('reads both levels from real astats output', () => { + expect(parseNoiseProfile(REAL_OUTPUT)).toEqual({ + rmsDb: -16.167788, + noiseFloorDb: -43.303773, + }); + }); + + it('turns -inf into null, not into a number', () => { + // Five of this project's eight fixtures report -inf. Number('-inf') is + // NaN and Number('-Infinity') is -Infinity; either one reaching + // shouldDenoise as a value would denoise the cleanest files in the set. + expect(parseNoiseProfile(INF_OUTPUT)).toEqual({ + rmsDb: -15.377982, + noiseFloorDb: null, + }); + }); + + it('answers nulls for output with no levels at all', () => { + expect(parseNoiseProfile('ffmpeg version 8.0')).toEqual(EMPTY_PROFILE); + }); + + it('answers nulls for empty output', () => { + expect(parseNoiseProfile('')).toEqual(EMPTY_PROFILE); + }); + + it('takes the overall figure when a per-channel one precedes it', () => { + // measure_perchannel=none is passed, but a future flag change must not + // silently make this read a single channel as the whole file. + const output = [ + '[Parsed_astats_0 @ 0x1] RMS level dB: -30.000000', + '[Parsed_astats_0 @ 0x1] Noise floor dB: -50.000000', + '[Parsed_astats_0 @ 0x1] Overall', + '[Parsed_astats_0 @ 0x1] RMS level dB: -16.000000', + '[Parsed_astats_0 @ 0x1] Noise floor dB: -43.000000', + ].join('\n'); + expect(parseNoiseProfile(output)).toEqual({ rmsDb: -16, noiseFloorDb: -43 }); + }); +}); + +describe('astatsArgs', () => { + it('scans without writing a file', () => { + const args = astatsArgs('/tmp/a.wav'); + expect(args).toContain('/tmp/a.wav'); + // `-f null -`, all three tokens. This is a measurement, not a + // conversion, so the null muxer discards the samples -- but the trailing + // `-` is still required: ffmpeg refuses to run without an output target, + // and an earlier version of this list omitted it. Every measurement then + // returned two nulls and `auto` never denoised anything, with every unit + // test still green. + expect(args.slice(-3)).toEqual(['-f', 'null', '-']); + expect(args.join(' ')).toContain('astats'); + }); + + it('asks only for the two fields the decision uses', () => { + expect(astatsArgs('/tmp/a.wav').join(' ')).toContain( + 'measure_overall=Noise_floor+RMS_level:measure_perchannel=none', + ); + }); +}); + +describe('denoiseArgs', () => { + it('applies a high-pass and a mild fft denoise, in that order', () => { + // highpass=f=80 removes rumble below speech fundamentals; afftdn=nf=-25 + // is deliberately mild. Anything stronger measurably costs accuracy on + // audio that was not very noisy to begin with. + expect(denoiseArgs('/tmp/in.wav', '/tmp/out.wav').join(' ')).toContain( + '-af highpass=f=80,afftdn=nf=-25', + ); + }); + + it('keeps the 16 kHz mono pcm shape whisper is fed', () => { + const args = denoiseArgs('/tmp/in.wav', '/tmp/out.wav').join(' '); + expect(args).toContain('-ac 1'); + expect(args).toContain('-ar 16000'); + expect(args).toContain('-c:a pcm_s16le'); + }); + + it('names the input and the output', () => { + const args = denoiseArgs('/tmp/in.wav', '/tmp/out.wav'); + expect(args).toContain('/tmp/in.wav'); + expect(args[args.length - 1]).toBe('/tmp/out.wav'); + }); +}); diff --git a/packages/providers/src/audio/noise.ts b/packages/providers/src/audio/noise.ts new file mode 100644 index 0000000..951b634 --- /dev/null +++ b/packages/providers/src/audio/noise.ts @@ -0,0 +1,97 @@ +import type { NoiseProfile } from '@ailoud/core'; + +/** What a measurement that produced nothing usable looks like. */ +export const EMPTY_PROFILE: NoiseProfile = { noiseFloorDb: null, rmsDb: null }; + +/** + * MEASURED against a real ffmpeg 8.0: astats prints its figures prefixed with + * the filter instance, e.g. + * `[Parsed_astats_0 @ 0x9348009c0] Noise floor dB: -43.303773`, on stderr. + * + * The value can also be the literal `-inf`, which is not an extreme number + * but the absence of one: five of this project's eight fixtures report it. + * Anything that is not a finite number becomes null here, so the decision + * upstream sees "no measurement" rather than a value it would misread. + */ +const LEVEL_LINE = (label: string): RegExp => + new RegExp(`${label}\\s+dB:\\s*(-?[\\d.]+|-?inf)`, 'gi'); + +function lastFinite(output: string, label: string): number | null { + let found: number | null = null; + for (const match of output.matchAll(LEVEL_LINE(label))) { + const raw = match[1]; + if (raw === undefined) continue; + const value = Number(raw); + // The LAST match, not the first: with per-channel measurement enabled a + // channel's figures print before the overall ones, and the overall file + // is what the decision is about. `measure_perchannel=none` means there is + // only one today, but a flag change must not silently start reading one + // channel as the whole recording. + found = Number.isFinite(value) ? value : null; + } + return found; +} + +export function parseNoiseProfile(output: string): NoiseProfile { + return { + rmsDb: lastFinite(output, 'RMS level'), + noiseFloorDb: lastFinite(output, 'Noise floor'), + }; +} + +/** + * Scans a wav and prints its levels, writing no output file. + * + * Measured cost: 0.03-0.07 s per project fixture, roughly a thousand times + * real time, so about three seconds for an hour of audio. That is what makes + * measuring every recording affordable. + */ +export function astatsArgs(wavPath: string): string[] { + return [ + '-hide_banner', + '-nostats', + '-i', + wavPath, + '-af', + 'astats=metadata=1:measure_overall=Noise_floor+RMS_level:measure_perchannel=none', + '-f', + 'null', + // The output target, and it is load-bearing. Without it ffmpeg answers + // "At least one output file must be specified", exits non-zero and prints + // no astats figures at all -- so every measurement comes back as two + // nulls and `auto` silently never denoises anything. `-` is stdout, which + // discards the samples because the format is null; nothing is written. + '-', + ]; +} + +/** + * The denoising chain, deliberately conservative. + * + * `highpass=f=80` drops rumble below speech fundamentals. `afftdn=nf=-25` is + * a mild FFT denoise. `arnndn` would be stronger and is not used: it needs a + * model file downloaded and provisioned, which is a whole new layer for a + * feature whose own measurements say five of eight fixtures never invoke it. + * + * The output shape matches `toWav16kMono`'s exactly, because this rewrites a + * file that has already been converted and whisper must not notice a + * difference beyond the filtering. + */ +export function denoiseArgs(input: string, output: string): string[] { + return [ + '-v', + 'error', + '-y', + '-i', + input, + '-af', + 'highpass=f=80,afftdn=nf=-25', + '-ac', + '1', + '-ar', + '16000', + '-c:a', + 'pcm_s16le', + output, + ]; +} diff --git a/packages/providers/src/diarize/sherpaDiarizer.test.ts b/packages/providers/src/diarize/sherpaDiarizer.test.ts index b957e3c..453fe5a 100644 --- a/packages/providers/src/diarize/sherpaDiarizer.test.ts +++ b/packages/providers/src/diarize/sherpaDiarizer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { parseSpeakerTurns, SherpaDiarizer } from './sherpaDiarizer.js'; // Real output shape, captured from sherpa-onnx v1.13.6 on a two-speaker file. @@ -111,3 +111,36 @@ describe('SherpaDiarizer', () => { await expect(d.turns('a.wav')).rejects.toThrow(/bad model/); }); }); + +describe('resource flags', () => { + async function argsFor(threads: number): Promise { + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + const diarizer = new SherpaDiarizer({ + binary: 'sherpa', + segmentationModel: '/seg.onnx', + embeddingModel: '/emb.onnx', + threshold: 0.6, + threads, + runner, + }); + await diarizer.turns('/a.wav'); + return runner.mock.calls[0]![1] as string[]; + } + + it('gives both passes the same thread count', async () => { + const args = await argsFor(6); + expect(args).toContain('--segmentation.num-threads=6'); + expect(args).toContain('--embedding.num-threads=6'); + }); + + it('never passes a provider flag', async () => { + // MEASURED: --segmentation.provider and --embedding.provider both exist + // and both work, and coreml is about twenty percent SLOWER than cpu on + // this project's reference machine (56.8 s against 45.2 s over 607 s of + // speech). cuda could not be measured at all -- there is no NVIDIA + // machine here. Shipping either would break the rule that a flag's + // benefit must be measured, not assumed. + const args = await argsFor(6); + expect(args.join(' ')).not.toContain('provider'); + }); +}); diff --git a/packages/providers/src/diarize/sherpaDiarizer.ts b/packages/providers/src/diarize/sherpaDiarizer.ts index f26a34b..97dfddc 100644 --- a/packages/providers/src/diarize/sherpaDiarizer.ts +++ b/packages/providers/src/diarize/sherpaDiarizer.ts @@ -37,10 +37,21 @@ export interface SherpaDiarizerOptions { readonly threshold: number; /** * Threads for each of the binary's two passes. Required, with no fallback - * here: the binary's own default is 1, which is half the speed the design - * measured, and the number belongs to config (`stt.diarization.threads`, - * where its default and reasoning live) rather than to this adapter, which - * has no business deciding how much of the user's machine to take. + * here: the binary's own default is 1, and the number belongs to the + * resource budget (core/resources/budget.ts), not to this adapter. + * + * This engine has an optimum rather than a maximum, which is why the budget + * gives it a capped share instead of the full ceiling. MEASURED on 607 s of + * speech, 8 performance cores: + * + * 1 thread 120.0 s 6 threads 45.2 s <- fastest + * 2 threads 72.6 s 7 threads 56.5 s + * 4 threads 50.3 s 8 threads 66-122 s + * 10 threads 105.6 s + * + * The binary holds two ONNX sessions, each with its own intra-op pool, so N + * threads per pass oversubscribes a machine with N performance cores. Do not + * "fix" the cap up to the ceiling: at 8 threads this is slower than at 1. */ readonly threads: number; readonly runner?: typeof defaultRunner; diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 307c42d..e14e296 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -26,8 +26,11 @@ export type { InstallSherpaOptions } from './provision/sherpaInstall.js'; export { NodeFs } from './system/nodeFs.js'; export { SystemClock, UlidIds } from './system/systemClock.js'; +export { cpuTopology, parsePerformanceCores } from './system/cpuTopology.js'; +export { parseBackends, probeBackends } from './system/accelerator.js'; export { FfmpegAudioTool } from './audio/ffmpeg.js'; +export { astatsArgs, denoiseArgs, parseNoiseProfile } from './audio/noise.js'; export { WhisperCppProvider, parseWhisperJson } from './stt/whisperCpp.js'; export type { WhisperCppOptions } from './stt/whisperCpp.js'; @@ -51,3 +54,10 @@ export { listOpenAiModels, listAnthropicModels, isChatModel } from './llm/models export type { ModelOption } from './llm/models.js'; export { LLAMA_VERSION, installLlama, llamaTarballUrl } from './provision/llamaInstall.js'; export type { InstallLlamaOptions, InstallLlamaResult } from './provision/llamaInstall.js'; + +export { DEFAULT_REGISTRY, DEFAULT_TIMEOUT_MS, NpmRegistry } from './update/npmRegistry.js'; +export type { RegistryTransport } from './update/npmRegistry.js'; +export type { NpmRegistryOptions } from './update/npmRegistry.js'; + +export { detectInstallMethod, installCommandFor, sweepCommandFor } from './update/installMethod.js'; +export type { InstallMethod, DetectOptions } from './update/installMethod.js'; diff --git a/packages/providers/src/llm/anthropic.ts b/packages/providers/src/llm/anthropic.ts index c097e19..8474029 100644 --- a/packages/providers/src/llm/anthropic.ts +++ b/packages/providers/src/llm/anthropic.ts @@ -1,5 +1,6 @@ import type { Summarizer } from '@ailoud/core'; import { EnvironmentError, FailureError } from '@ailoud/core'; +import { withoutTrailingSlashes } from '@ailoud/core'; /** As for the OpenAI adapter: a hosted call that has not returned in five minutes is not going to. */ const REQUEST_TIMEOUT_MS = 5 * 60_000; @@ -71,7 +72,7 @@ export class AnthropicSummarizer implements Summarizer { } public async complete(prompt: string): Promise { - const url = `${this.options.baseUrl.replace(/\/+$/, '')}/messages`; + const url = `${withoutTrailingSlashes(this.options.baseUrl)}/messages`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); diff --git a/packages/providers/src/llm/llamaCpp.ts b/packages/providers/src/llm/llamaCpp.ts index 2cc98d2..5fd397a 100644 --- a/packages/providers/src/llm/llamaCpp.ts +++ b/packages/providers/src/llm/llamaCpp.ts @@ -19,7 +19,11 @@ export interface LlamaCppOptions { readonly contextTokens: number; /** Hard cap on the answer, so a model that starts looping cannot run forever. */ readonly maxOutputTokens: number; - readonly threads?: number; + /** + * Threads for generation. Required rather than optional: leaving it out + * left llama-cli on its own default of 4 whatever the machine had. + */ + readonly threads: number; readonly runner?: typeof defaultRunner; } @@ -87,7 +91,8 @@ export class LlamaCppSummarizer implements Summarizer { // waiting for input that is never coming. '-no-cnv', '--single-turn', - ...(this.options.threads === undefined ? [] : ['-t', String(this.options.threads)]), + '-t', + String(this.options.threads), // -f rather than -p: a prompt carrying a transcript does not fit in an // argument. ARG_MAX is about a megabyte on macOS, less once the // environment is counted, and the spawn then fails with E2BIG -- a diff --git a/packages/providers/src/llm/llm.test.ts b/packages/providers/src/llm/llm.test.ts index 07145b5..cbcb3b8 100644 --- a/packages/providers/src/llm/llm.test.ts +++ b/packages/providers/src/llm/llm.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { FailureError } from '@ailoud/core'; import { LlamaCppSummarizer, cleanCompletion } from './llamaCpp.js'; import { OpenAiCompatibleSummarizer, extractCompletion } from './openAiCompatible.js'; @@ -40,6 +40,7 @@ describe('LlamaCppSummarizer', () => { modelPath: '/m.gguf', contextTokens: 8192, maxOutputTokens: 512, + threads: 4, runner: runnerFn as never, }); } @@ -77,6 +78,38 @@ describe('LlamaCppSummarizer', () => { const { fn } = runner({ code: 0, stdout: ' ', stderr: '' }); await expect(make(fn).complete('p')).rejects.toThrow(/too large for the configured context/); }); + + it('passes the thread count it was given', async () => { + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: 'a summary', stderr: '' }); + const summarizer = new LlamaCppSummarizer({ + binary: 'llama-cli', + modelPath: '/m.gguf', + contextTokens: 8192, + maxOutputTokens: 1024, + threads: 7, + runner, + }); + await summarizer.complete('prompt'); + expect(runner.mock.calls[0]![1]).toEqual(expect.arrayContaining(['-t', '7'])); + }); + + it('never passes -ngl', async () => { + // Dropped for want of a measurement: llama-cli is not installed on the + // machine this feature was built on, so neither the flag's presence nor + // its benefit could be confirmed. Recent llama.cpp offloads to Metal and + // CUDA by default when built for them, so the default is already right. + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: 'a summary', stderr: '' }); + const summarizer = new LlamaCppSummarizer({ + binary: 'llama-cli', + modelPath: '/m.gguf', + contextTokens: 8192, + maxOutputTokens: 1024, + threads: 7, + runner, + }); + await summarizer.complete('prompt'); + expect(runner.mock.calls[0]![1]).not.toContain('-ngl'); + }); }); describe('extractCompletion', () => { diff --git a/packages/providers/src/llm/models.ts b/packages/providers/src/llm/models.ts index 9971b12..c96f35d 100644 --- a/packages/providers/src/llm/models.ts +++ b/packages/providers/src/llm/models.ts @@ -1,4 +1,5 @@ import { EnvironmentError, FailureError } from '@ailoud/core'; +import { withoutTrailingSlashes } from '@ailoud/core'; /** As elsewhere in this directory: a hosted call that has not answered in a minute is not going to. */ const REQUEST_TIMEOUT_MS = 60_000; @@ -77,7 +78,7 @@ export async function listOpenAiModels( apiKey: string | undefined, fetchImpl: typeof fetch = fetch, ): Promise { - const url = `${baseUrl.replace(/\/+$/, '')}/models`; + const url = `${withoutTrailingSlashes(baseUrl)}/models`; const body = (await getJson( url, apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` }, @@ -104,7 +105,7 @@ export async function listAnthropicModels( apiKey: string, fetchImpl: typeof fetch = fetch, ): Promise { - const root = `${baseUrl.replace(/\/+$/, '')}/models`; + const root = `${withoutTrailingSlashes(baseUrl)}/models`; const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }; const collected: ModelOption[] = []; let after: string | undefined; diff --git a/packages/providers/src/llm/openAiCompatible.ts b/packages/providers/src/llm/openAiCompatible.ts index 69c547c..ac91d01 100644 --- a/packages/providers/src/llm/openAiCompatible.ts +++ b/packages/providers/src/llm/openAiCompatible.ts @@ -1,5 +1,6 @@ import type { Summarizer } from '@ailoud/core'; import { EnvironmentError, FailureError } from '@ailoud/core'; +import { withoutTrailingSlashes } from '@ailoud/core'; /** * A hosted model does not get the hour a local one does. If a request has not @@ -61,7 +62,7 @@ export class OpenAiCompatibleSummarizer implements Summarizer { } public async complete(prompt: string): Promise { - const url = `${this.options.baseUrl.replace(/\/+$/, '')}/chat/completions`; + const url = `${withoutTrailingSlashes(this.options.baseUrl)}/chat/completions`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); diff --git a/packages/providers/src/process/run.test.ts b/packages/providers/src/process/run.test.ts index 6bf034c..54d18cc 100644 --- a/packages/providers/src/process/run.test.ts +++ b/packages/providers/src/process/run.test.ts @@ -38,6 +38,52 @@ describe('run', () => { const result = await run('node', ['-e', 'process.kill(process.pid, "SIGTERM")']); expect(result.code).toBe(128 + 15); }); + + it('delivers stderr lines as they arrive, without their newline', async () => { + const lines: string[] = []; + await run('node', ['-e', 'process.stderr.write("a\\nb\\n")'], { + onStderrLine: (line) => lines.push(line), + }); + expect(lines).toEqual(['a', 'b']); + }); + + it('delivers a trailing fragment that never got its newline', async () => { + const lines: string[] = []; + await run('node', ['-e', 'process.stderr.write("a\\nb")'], { + onStderrLine: (line) => lines.push(line), + }); + expect(lines).toEqual(['a', 'b']); + }); + + it('reassembles a line split across two chunks', async () => { + const lines: string[] = []; + // Two writes with a tick between them, so the runtime cannot coalesce + // them into one 'data' event. A naive per-chunk split loses "hello". + await run( + 'node', + ['-e', 'process.stderr.write("hel"); setTimeout(() => process.stderr.write("lo\\n"), 50);'], + { onStderrLine: (line) => lines.push(line) }, + ); + expect(lines).toEqual(['hello']); + }); + + it('buffers stderr identically whether or not a line sink is passed', async () => { + const args = ['-e', 'process.stderr.write("one\\ntwo\\n")']; + const without = await run('node', args); + const with_ = await run('node', args, { onStderrLine: () => {} }); + expect(with_.stderr).toBe(without.stderr); + expect(with_.stderr).toBe('one\ntwo\n'); + }); + + it('survives a line sink that throws', async () => { + const result = await run('node', ['-e', 'process.stderr.write("x\\n"); process.exit(0)'], { + onStderrLine: () => { + throw new Error('sink exploded'); + }, + }); + expect(result.code).toBe(0); + expect(result.stderr).toBe('x\n'); + }); }); describe('runInteractive', () => { diff --git a/packages/providers/src/process/run.ts b/packages/providers/src/process/run.ts index c355fc0..683ddb0 100644 --- a/packages/providers/src/process/run.ts +++ b/packages/providers/src/process/run.ts @@ -19,6 +19,20 @@ export interface RunOptions { * with E2BIG, which is a failure the user can do nothing about. */ readonly stdin?: string; + /** + * Called once per complete line of stderr, as it arrives, without the + * trailing newline. A final fragment that never got a newline is delivered + * on close. + * + * Additive: `stderr` in the result is still the whole stream, byte for + * byte, whether or not this is passed. The buffering was not replaced by + * this, it was left alone -- every transcription in the tool goes through + * this function, and a progress feature is not worth risking it. + * + * Never lets a sink's exception escape. A caller watching for progress + * must not be able to fail the command it is watching. + */ + readonly onStderrLine?: (line: string) => void; } const DEFAULT_TIMEOUT_MS = 30 * 60_000; @@ -65,11 +79,30 @@ export function run( child.kill('SIGKILL'); }, timeoutMs); + // Held between chunks: a line is routinely split across two 'data' + // events, and splitting each chunk on its own loses whatever straddled + // the boundary. Measured on whisper-cli, which emits ~104 stderr lines + // per run. + let pending = ''; + const emit = (line: string): void => { + try { + options.onStderrLine?.(line); + } catch { + // A progress sink is an observer. It does not get to fail the run. + } + }; + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString('utf8'); + const text = chunk.toString('utf8'); + stderr += text; + if (options.onStderrLine === undefined) return; + pending += text; + const parts = pending.split('\n'); + pending = parts.pop() ?? ''; + for (const part of parts) emit(part); }); child.on('error', (error: NodeJS.ErrnoException) => { @@ -87,6 +120,10 @@ export function run( child.on('close', (code, signal) => { clearTimeout(timer); + if (pending !== '') { + emit(pending); + pending = ''; + } if (timedOut) { // A timeout is not "the machine is not set up": the binary was // found and started fine, it just did not finish in the time this diff --git a/packages/providers/src/store/schema.lock.json b/packages/providers/src/store/schema.lock.json new file mode 100644 index 0000000..7b4dbaa --- /dev/null +++ b/packages/providers/src/store/schema.lock.json @@ -0,0 +1,9 @@ +{ + "1": "990b9debc296c324211775c6d34d80942a4ea049d9d6bc981f74f6810d8c0817", + "2": "3e6171bd33a660c4b7ccb686d4204b7d4533cbed2724d6714054023d89e4f4bc", + "3": "bbb212932dd1c81d6c81606e87097af6e2c91fd650f173b71288067d878d8de1", + "4": "04f859176f9c0dafc4051220f404ef93e29397427a9063da02eaa24160b8a5c4", + "5": "abe7451f10eea7efb50ae73f2817970e2a1afff58dc6676a970a9d78f7eda867", + "6": "e6bd7848b47e358a03ebc4f3153d4cdd6a827d99a44952c5420883fa5c9df90c", + "7": "65c9b56640066a89ec6f6c35a00c8aaf693cba6c838f031496a7a489d3a04e50" +} diff --git a/packages/providers/src/store/schema.snapshot.sql b/packages/providers/src/store/schema.snapshot.sql new file mode 100644 index 0000000..86e4faf --- /dev/null +++ b/packages/providers/src/store/schema.snapshot.sql @@ -0,0 +1,86 @@ +CREATE INDEX summary_by_recording ON summary_recording(recording_id); + +CREATE INDEX tag_by_name ON tag(tag); + +CREATE INDEX transcript_by_recording ON transcript(recording_id, created_at DESC); + +CREATE TABLE recording ( + id TEXT PRIMARY KEY, + sha256 TEXT NOT NULL UNIQUE, + source_path TEXT NOT NULL, + media_path TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + mime TEXT NOT NULL, + title TEXT, + notes TEXT, + imported_at TEXT NOT NULL + , recorded_at TEXT); + +CREATE TABLE segment ( + id TEXT PRIMARY KEY, + transcript_id TEXT NOT NULL REFERENCES transcript(id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + text TEXT NOT NULL, + speaker TEXT, + language TEXT, + UNIQUE (transcript_id, idx) + ); + +CREATE VIRTUAL TABLE segment_fts USING fts5( + text, content='segment', content_rowid='rowid' + ); + +CREATE TABLE speaker ( + recording_id TEXT NOT NULL REFERENCES recording(id) ON DELETE CASCADE, + label TEXT NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (recording_id, label) + ); + +CREATE TABLE summary ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + language TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + body TEXT NOT NULL + , template TEXT NOT NULL DEFAULT 'meeting', context TEXT NOT NULL DEFAULT ''); + +CREATE TABLE summary_recording ( + summary_id TEXT NOT NULL REFERENCES summary(id) ON DELETE CASCADE, + recording_id TEXT NOT NULL REFERENCES recording(id) ON DELETE CASCADE, + PRIMARY KEY (summary_id, recording_id) + ); + +CREATE TABLE tag ( + recording_id TEXT NOT NULL REFERENCES recording(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (recording_id, tag) + ); + +CREATE TABLE transcript ( + id TEXT PRIMARY KEY, + recording_id TEXT NOT NULL REFERENCES recording(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + model TEXT NOT NULL, + language TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL + ); + +CREATE TRIGGER segment_ad AFTER DELETE ON segment BEGIN + INSERT INTO segment_fts(segment_fts, rowid, text) + VALUES ('delete', old.rowid, old.text); + END; + +CREATE TRIGGER segment_ai AFTER INSERT ON segment BEGIN + INSERT INTO segment_fts(rowid, text) VALUES (new.rowid, new.text); + END; + +CREATE TRIGGER segment_fts_update AFTER UPDATE ON segment BEGIN + INSERT INTO segment_fts(segment_fts, rowid, text) + VALUES ('delete', old.rowid, old.text); + INSERT INTO segment_fts(rowid, text) VALUES (new.rowid, new.text); + END; diff --git a/packages/providers/src/store/schemaGuard.test.ts b/packages/providers/src/store/schemaGuard.test.ts new file mode 100644 index 0000000..5951dec --- /dev/null +++ b/packages/providers/src/store/schemaGuard.test.ts @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { Migration } from '@ailoud/core'; +import { MIGRATIONS } from '@ailoud/core'; + +/** + * Two guards against the same failure mode: a schema change that ships + * without going through the migration list, or a migration edited after it + * has already run on someone's machine. + * + * A database that already ran migration 4 will never run it again -- SQLite + * only replays the migrations above its stored `user_version`. So an edited + * migration 4 does not fail loudly; it means two machines sitting at the + * same `user_version` with DIFFERENT schemas, which nothing here or in + * SqliteStore would ever notice on its own. The lock file is the only thing + * that would. + * + * schema.lock.json and schema.snapshot.sql are generated by + * scripts/write-schema-snapshot.mjs (run it after adding a migration, never + * to silence a failure here about an already-shipped one -- see + * .agents/skills/check-migrations/SKILL.md). + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const LOCK = join(here, 'schema.lock.json'); +const SNAPSHOT = join(here, 'schema.snapshot.sql'); + +const lock = JSON.parse(readFileSync(LOCK, 'utf8')) as Record; + +/** + * Hashes a migration's statements only, never the comments around them in + * schema.ts. That is not a parsing choice -- the comments are TypeScript + * comments outside the template-literal strings, so they were never part of + * `migration.statements` at runtime, and editing one cannot change this + * value. The comments explaining why a column or table exists are the house + * style and must stay freely editable without touching the lock. + * + * A record-separator character (0x1E), not ';', goes between statements. + * A semicolon can appear INSIDE a statement -- every trigger body in + * schema.ts (segment_ai, segment_ad, segment_fts_update) contains several, + * between its BEGIN...END sub-statements -- so joining with ';' lets two + * different splits of the same bytes collide on the same hash (e.g. + * ["a;b", "c"] and ["a", "b;c"] both concatenate to "a;b;c;"). 0x1E cannot + * appear in any SQL statement written in schema.ts, so this join has no such + * collision. Keep this in exact step with the same-named function in + * scripts/write-schema-snapshot.mjs -- both must compute the same thing + * from the same MIGRATIONS, or a passing test here proves nothing. + */ +const FINGERPRINT_SEPARATOR = '\x1e'; + +function fingerprint(migration: Migration): string { + const hash = createHash('sha256'); + for (const statement of migration.statements) { + hash.update(statement); + hash.update(FINGERPRINT_SEPARATOR); + } + return hash.digest('hex'); +} + +/** + * True only if `versions` is exactly [1, 2, ..., versions.length], in that + * order -- no gap, no duplicate, and no entry whose value disagrees with its + * position. SCHEMA_VERSION (packages/core/src/db/schema.ts) is defined as + * MIGRATIONS.length, which silently assumes exactly this. A gap -- say + * versions [1, 2, 4], version 3 skipped by a typo when adding a new one -- + * lets SqliteStore.migrate write PRAGMA user_version = 4 while + * SCHEMA_VERSION stays 3 (MIGRATIONS.length is still only 3 entries), so + * pendingMigrations then refuses to reopen the very database that build just + * created, as "newer than this build understands". This is asserted here, + * as a loud development-time failure, rather than by redefining + * SCHEMA_VERSION: that constant is what `user_version` MEANS in every + * database that already exists, and changing its definition would be a + * migration hazard of its own. + */ +function isContiguousFromOne(versions: readonly number[]): boolean { + return versions.every((version, index) => version === index + 1); +} + +/** + * A fresh in-memory database with every migration applied, in order. Closes + * `db` before rethrowing if any statement fails partway through: otherwise a + * bad migration would leak the handle before it even reaches the caller's + * own try/finally, since the caller never receives a reference to close. + */ +function migratedDatabase(): DatabaseSync { + const db = new DatabaseSync(':memory:'); + try { + for (const migration of MIGRATIONS) { + for (const statement of migration.statements) db.exec(statement); + } + } catch (error) { + db.close(); + throw error; + } + return db; +} + +/** + * A deterministic text dump of the schema a database ends up with: every + * table, index, and trigger that carries its own SQL, sorted by type then + * name so the output does not depend on creation order. `sql IS NOT NULL` + * excludes the automatic indexes SQLite creates for a composite PRIMARY KEY, + * which store no SQL of their own and are implied by the table that owns + * them. + * + * Also excludes FTS5's shadow tables (segment_fts_config, _data, _docsize, + * _idx: `type = 'table' AND name LIKE 'segment\_fts\_%'`). Those are not + * written by any statement in schema.ts -- they are generated internally by + * the FTS5 extension bundled with whichever SQLite build node:sqlite links, + * which is still experimental in Node 24 with CI pinning only the major + * version. Their exact DDL text (column names, WITHOUT ROWID, quoting + * style) can change on a Node/SQLite upgrade with zero change to any + * migration, which would fail this guard for a reason unrelated to its + * subject -- and a guard that fails for reasons unrelated to its subject + * teaches people to ignore it. `CREATE VIRTUAL TABLE segment_fts` itself is + * NOT excluded (it doesn't match the shadow-table name pattern): the + * declaration stays guarded, only its generated implementation detail does + * not. + * + * This is generated BY the migrations, never hand-edited, so it cannot drift + * from them by construction -- only regeneration can change it, and + * regeneration is scripts/write-schema-snapshot.mjs, which a reviewer sees + * as a diff. + */ +function dumpSchema(db: DatabaseSync): string { + const rows = db + .prepare( + `SELECT sql FROM sqlite_master + WHERE sql IS NOT NULL + AND NOT (type = 'table' AND name LIKE 'segment\\_fts\\_%' ESCAPE '\\') + ORDER BY type, name`, + ) + .all() as unknown as { sql: string }[]; + return rows.map((row) => `${row.sql};`).join('\n\n') + '\n'; +} + +describe('schema guard', () => { + it('has a lock entry for every migration, and no extras', () => { + expect( + Object.keys(lock) + .map(Number) + .sort((a, b) => a - b), + ).toEqual(MIGRATIONS.map((m) => m.version)); + }); + + it('still matches the fingerprint of every shipped migration', () => { + // A database that already ran version 4 will never run it again, so an + // edited version 4 means two machines at the same user_version with + // different schemas. Adding a migration means adding a lock line, which + // is a visible diff in review. + for (const migration of MIGRATIONS) { + expect(fingerprint(migration)).toBe(lock[String(migration.version)]); + } + }); + + it('produces exactly the snapshotted schema', () => { + // Generated BY the migrations, so it cannot drift from them. Any + // structural change fails here until the snapshot is regenerated, which + // puts every schema change in front of a reviewer. + const db = migratedDatabase(); + try { + expect(dumpSchema(db)).toBe(readFileSync(SNAPSHOT, 'utf8')); + } finally { + db.close(); + } + }); + + it('has version numbers contiguous from 1, with no gap and no duplicate', () => { + expect(isContiguousFromOne(MIGRATIONS.map((m) => m.version))).toBe(true); + }); + + it('rejects a gap in version numbers', () => { + expect(isContiguousFromOne([1, 2, 4])).toBe(false); + }); + + it('rejects a duplicate version number', () => { + expect(isContiguousFromOne([1, 2, 2])).toBe(false); + }); + + it('rejects a version that disagrees with its position in the array', () => { + expect(isContiguousFromOne([1, 3, 2])).toBe(false); + }); + + it('does not let statement content collide across the separator', () => { + // Every trigger body in schema.ts contains internal semicolons, so if + // the separator between statements were ';', two differently-split + // statement lists could concatenate to the identical byte stream and + // hash the same. This is not hypothetical: ['a;b', 'c'] and + // ['a', 'b;c'] both produced 'a;b;c;' under the old ';' separator. + const a: Migration = { version: 1, statements: ['a;b', 'c'] }; + const b: Migration = { version: 1, statements: ['a', 'b;c'] }; + expect(fingerprint(a)).not.toBe(fingerprint(b)); + }); + + it('excludes FTS5 shadow tables from the snapshot but keeps segment_fts itself', () => { + const snapshot = readFileSync(SNAPSHOT, 'utf8'); + expect(snapshot).toContain('CREATE VIRTUAL TABLE segment_fts'); + expect(snapshot).not.toMatch(/segment_fts_(config|data|docsize|idx)/); + }); +}); diff --git a/packages/providers/src/stt/whisperCpp.test.ts b/packages/providers/src/stt/whisperCpp.test.ts index 7f8dae2..8b94cda 100644 --- a/packages/providers/src/stt/whisperCpp.test.ts +++ b/packages/providers/src/stt/whisperCpp.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { parseDetectedLanguage, parseWhisperJson, WhisperCppProvider } from './whisperCpp.js'; +import { + parseDetectedLanguage, + parseProgressPercent, + parseWhisperJson, + WhisperCppProvider, +} from './whisperCpp.js'; const WHISPER_OUTPUT = JSON.stringify({ result: { language: 'ru' }, @@ -55,6 +60,31 @@ describe('parseDetectedLanguage', () => { }); }); +describe('parseProgressPercent', () => { + it('reads the line whisper actually prints', () => { + // Measured against whisper-cli (Homebrew, ggml-small.bin). Note the + // two spaces of padding before a two-digit number. + expect(parseProgressPercent('whisper_print_progress_callback: progress = 46%')).toBe(46); + }); + + it('reads an unpadded hundred', () => { + expect(parseProgressPercent('whisper_print_progress_callback: progress = 100%')).toBe(100); + }); + + it('returns null for any other line rather than throwing', () => { + for (const line of [ + '', + 'whisper_init_from_file_with_params_no_state: loading model', + 'whisper_print_progress_callback: progress = ??%', + 'progress = 46', + 'whisper_print_progress_callback: progress = -1%', + 'whisper_print_progress_callback: progress = 101%', + ]) { + expect(parseProgressPercent(line)).toBeNull(); + } + }); +}); + describe('WhisperCppProvider', () => { it('passes the model, the audio, and the language hint', async () => { const runner = vi.fn(async () => ({ code: 0, stdout: '', stderr: '' })); @@ -62,6 +92,8 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/base.bin', + threads: 4, + gpu: true, runner, readFile, }); @@ -70,7 +102,20 @@ describe('WhisperCppProvider', () => { expect(runner).toHaveBeenCalledWith( 'whisper-cli', - ['-m', '/models/base.bin', '-f', '/tmp/a.wav', '-l', 'ru', '-oj', '-of', '/tmp/a'], + [ + '-m', + '/models/base.bin', + '-f', + '/tmp/a.wav', + '-l', + 'ru', + '-t', + '4', + '-oj', + '-pp', + '-of', + '/tmp/a', + ], expect.anything(), ); expect(result.language).toBe('ru'); @@ -87,6 +132,8 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/base.bin', + threads: 4, + gpu: true, runner, readFile: async () => WHISPER_OUTPUT, }); @@ -99,6 +146,8 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/base.bin', + threads: 4, + gpu: true, runner, readFile: async () => WHISPER_OUTPUT, }); @@ -107,7 +156,20 @@ describe('WhisperCppProvider', () => { expect(runner).toHaveBeenCalledWith( 'whisper-cli', - ['-m', '/models/large.bin', '-f', '/tmp/a.wav', '-l', 'auto', '-oj', '-of', '/tmp/a'], + [ + '-m', + '/models/large.bin', + '-f', + '/tmp/a.wav', + '-l', + 'auto', + '-t', + '4', + '-oj', + '-pp', + '-of', + '/tmp/a', + ], expect.anything(), ); expect(result.model).toBe('large.bin'); @@ -117,6 +179,8 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/base.bin', + threads: 4, + gpu: true, runner: async () => ({ code: 1, stdout: '', stderr: 'model load failed' }), readFile: async () => '', }); @@ -138,6 +202,8 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/base.bin', + threads: 4, + gpu: true, runner, readFile: async () => WHISPER_OUTPUT, }); @@ -154,6 +220,8 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/base.bin', + threads: 4, + gpu: true, runner: async () => ({ code: 0, stdout: '', stderr: '' }), readFile: async () => { throw new Error('ENOENT: no such file or directory'); @@ -171,13 +239,15 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/small.bin', + threads: 4, + gpu: true, runner, readFile: async () => '', }); await expect(provider.detectLanguage('/tmp/a.wav')).resolves.toBe('en'); expect(runner).toHaveBeenCalledWith( 'whisper-cli', - ['-m', '/models/small.bin', '-f', '/tmp/a.wav', '-dl'], + ['-m', '/models/small.bin', '-f', '/tmp/a.wav', '-t', '4', '-dl'], expect.anything(), ); }); @@ -191,14 +261,170 @@ describe('WhisperCppProvider', () => { const provider = new WhisperCppProvider({ binary: 'whisper-cli', modelPath: '/models/small.bin', + threads: 4, + gpu: true, runner, readFile: async () => '', }); await provider.detectLanguage('/tmp/a.wav', { model: '/models/large.bin' }); expect(runner).toHaveBeenCalledWith( 'whisper-cli', - ['-m', '/models/large.bin', '-f', '/tmp/a.wav', '-dl'], + ['-m', '/models/large.bin', '-f', '/tmp/a.wav', '-t', '4', '-dl'], expect.anything(), ); }); + + it('passes -pp so whisper prints progress at all', async () => { + let seen: readonly string[] = []; + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/models/m.bin', + threads: 4, + gpu: true, + runner: async (_binary, args) => { + seen = args; + return { code: 0, stdout: '', stderr: '' }; + }, + readFile: async () => JSON.stringify({ result: { language: 'en' }, transcription: [] }), + }); + await provider.transcribe('/tmp/a.wav', {}); + expect(seen).toContain('-pp'); + }); + + it('reports whisper progress as a fraction', async () => { + const seen: number[] = []; + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/models/m.bin', + threads: 4, + gpu: true, + runner: async (_binary, _args, options) => { + options?.onStderrLine?.('whisper_print_progress_callback: progress = 46%'); + options?.onStderrLine?.('ggml_metal_init: found device'); + options?.onStderrLine?.('whisper_print_progress_callback: progress = 100%'); + return { code: 0, stdout: '', stderr: '' }; + }, + readFile: async () => + JSON.stringify({ + result: { language: 'en' }, + transcription: [{ offsets: { from: 0, to: 10 }, text: ' hi' }], + }), + }); + await provider.transcribe('/tmp/a.wav', { onProgress: (f) => seen.push(f) }); + expect(seen).toEqual([0.46, 1]); + }); + + it('transcribes normally when the progress sink throws', async () => { + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/models/m.bin', + threads: 4, + gpu: true, + runner: async (_binary, _args, options) => { + options?.onStderrLine?.('whisper_print_progress_callback: progress = 46%'); + return { code: 0, stdout: '', stderr: '' }; + }, + readFile: async () => + JSON.stringify({ + result: { language: 'en' }, + transcription: [{ offsets: { from: 0, to: 10 }, text: ' hi' }], + }), + }); + const result = await provider.transcribe('/tmp/a.wav', { + onProgress: () => { + throw new Error('sink exploded'); + }, + }); + expect(result.segments).toHaveLength(1); + }); +}); + +describe('resource flags', () => { + function capture() { + const runner = vi.fn(async (_command: string, _args: readonly string[]) => ({ + code: 0, + stdout: '', + stderr: '', + })); + return { runner, args: (): string[] => runner.mock.calls[0]![1] as string[] }; + } + + it('passes the thread count it was given', async () => { + const { runner, args } = capture(); + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/m.bin', + threads: 7, + gpu: true, + runner, + readFile: async () => JSON.stringify({ transcription: [{ text: 'hi' }] }), + }); + await provider.transcribe('/a.wav', {}); + expect(args()).toEqual(expect.arrayContaining(['-t', '7'])); + }); + + it('leaves the GPU alone by default, passing no -ng', async () => { + // MEASURED: a homebrew whisper-cli already loads Metal with no flag from + // us. -ng would turn that off, so its absence is the feature. + const { runner, args } = capture(); + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/m.bin', + threads: 7, + gpu: true, + runner, + readFile: async () => JSON.stringify({ transcription: [{ text: 'hi' }] }), + }); + await provider.transcribe('/a.wav', {}); + expect(args()).not.toContain('-ng'); + }); + + it('passes -ng when the GPU is turned off', async () => { + const { runner, args } = capture(); + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/m.bin', + threads: 7, + gpu: false, + runner, + readFile: async () => JSON.stringify({ transcription: [{ text: 'hi' }] }), + }); + await provider.transcribe('/a.wav', {}); + expect(args()).toContain('-ng'); + }); + + it('passes the thread count to language detection too', async () => { + // detectLanguage loads the same model, and the multilingual path runs it + // once per unit -- the place where a thread count matters most. + const runner = vi + .fn() + .mockResolvedValue({ code: 0, stdout: 'auto-detected language: ru', stderr: '' }); + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/m.bin', + threads: 5, + gpu: true, + runner, + }); + await provider.detectLanguage('/a.wav'); + expect(runner.mock.calls[0]![1]).toEqual(expect.arrayContaining(['-t', '5'])); + }); + + it('never passes a flag that was measured and rejected', async () => { + // sherpa's provider flag and llama's -ngl were both measured slower or + // unmeasurable and dropped. -p stays at whisper's own 1: raising it + // decodes independent chunks and loses context at every boundary. + const { runner, args } = capture(); + const provider = new WhisperCppProvider({ + binary: 'whisper-cli', + modelPath: '/m.bin', + threads: 7, + gpu: true, + runner, + readFile: async () => JSON.stringify({ transcription: [{ text: 'hi' }] }), + }); + await provider.transcribe('/a.wav', {}); + expect(args()).not.toContain('-p'); + expect(args()).not.toContain('-ngl'); + }); }); diff --git a/packages/providers/src/stt/whisperCpp.ts b/packages/providers/src/stt/whisperCpp.ts index ca8f308..4717a08 100644 --- a/packages/providers/src/stt/whisperCpp.ts +++ b/packages/providers/src/stt/whisperCpp.ts @@ -4,13 +4,13 @@ import type { RawSegment, TranscriptionProvider } from '@ailoud/core'; import { FailureError } from '@ailoud/core'; import { run as defaultRunner } from '../process/run.js'; -// NOT VERIFIED AGAINST A REAL BUILD: this JSON shape ("-oj" output: a -// top-level "result.language" and a "transcription" array of segments with -// "offsets.from"/"offsets.to" and "text") is written against whisper.cpp's -// documented output, with no whisper-cli binary available in this -// environment to confirm it against a real run. See buildWhisperArgs below -// for the sibling warning on the argument list; both get confirmed by the -// end-to-end suite once it runs against a real binary. +// VERIFIED against a real build: this JSON shape ("-oj" output: a top-level +// "result.language" and a "transcription" array of segments with +// "offsets.from"/"offsets.to" and "text") was confirmed by running +// homebrew's whisper-cli over fixtures/en-short.wav and parsing the result +// through parseWhisperJson below, which returned the fixture's reference +// sentence and its language. This comment used to warn that no binary was +// available to check it; one is, and it agrees. interface WhisperJson { result?: { language?: string }; transcription?: Array<{ offsets?: { from?: number; to?: number }; text?: string }>; @@ -30,6 +30,28 @@ export function parseDetectedLanguage(output: string): string { return match[1].toLowerCase(); } +/** + * Reads whisper's progress line, or returns null. + * + * MEASURED, not guessed: `whisper-cli` with `-pp` prints + * `whisper_print_progress_callback: progress = 46%` to stderr, with + * variable padding before the number, and fires once per decoded + * segment rather than on fixed steps. A 57-second fixture produced three + * lines; an hour-long recording produces hundreds. + * + * Returns null rather than throwing for anything it does not recognise -- + * including a percentage outside 0..100. This runs on all ~104 stderr lines + * of every run, and a parser that throws here would abort a transcription + * over a cosmetic feature. + */ +export function parseProgressPercent(line: string): number | null { + const match = /progress\s*=\s*(\d{1,3})%/.exec(line); + if (match?.[1] === undefined) return null; + const percent = Number(match[1]); + if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null; + return percent; +} + /** * Pure parser for whisper-cli's "-oj" JSON output. * @@ -60,26 +82,69 @@ export function parseWhisperJson(raw: string): { language: string; segments: Raw /** * Builds the whisper-cli argument array for one transcription run. * - * NOT VERIFIED AGAINST A REAL BUILD: these flags (-m model path, -f input - * file, -l language or "auto", -oj JSON output, -of output base path) are - * written against whisper.cpp's documented command-line interface. No - * whisper-cli binary is available in this environment to confirm them - * against an actual build. The end-to-end suite runs against a real binary - * and is where this argument list gets confirmed; if a flag turns out to - * differ there, fix it here in this one place. + * VERIFIED against a real build, every flag: `-m`, `-f`, `-l`, `-t`, `-ng`, + * `-oj`, `-pp` and `-of` were all read out of `whisper-cli --help` on a + * homebrew ggml 0.22.0 build, and this whole list was then run over + * fixtures/en-short.wav and produced the fixture's reference transcript. + * This comment used to say the opposite -- that no binary was available and + * the end-to-end suite would have to confirm it later. It has been confirmed. + * + * Keep it that way. An argument list that has only been read in + * documentation is one a unit test with a mocked runner will happily pass + * while the real binary refuses to start: that is exactly how the sibling + * noise scan in ../audio/noise.ts shipped without its output target, green + * tests and all. */ function buildWhisperArgs( modelPath: string, audioPath: string, language: string | undefined, outputBase: string, + threads: number, + gpu: boolean, ): string[] { - return ['-m', modelPath, '-f', audioPath, '-l', language ?? 'auto', '-oj', '-of', outputBase]; + return [ + '-m', + modelPath, + '-f', + audioPath, + '-l', + language ?? 'auto', + '-t', + String(threads), + // -p (processors) is deliberately left at the binary's own 1. It decodes + // N independent chunks in parallel and loses context at every boundary, + // which trades accuracy for speed -- not the trade this feature is for. + ...(gpu ? [] : ['-ng']), + '-oj', + '-pp', + '-of', + outputBase, + ]; } export interface WhisperCppOptions { readonly binary: string; readonly modelPath: string; + /** + * Threads for the CPU side of the run. Required, with no fallback: the + * binary's own default is 4 whatever the machine has, and an adapter + * quietly accepting that is how this went unnoticed. The number belongs to + * the resource budget (core/resources/budget.ts), not here. + * + * Worth knowing before tuning it: on a build with a GPU backend this + * barely matters. MEASURED on an M1 Pro, 607 s of speech: 16.56 s at -t 4, + * 16.33 s at 6, 16.23 s at 8 -- two percent across the range, because the + * encoder runs on Metal. On a CPU-only build the same flag is worth several + * times the runtime, which is why it is still passed. + */ + readonly threads: number; + /** + * False adds `-ng`. True adds nothing at all: a homebrew whisper-cli + * already loads Metal on its own (measured), so using the GPU is the + * default behaviour and this flag exists only to turn it off. + */ + readonly gpu: boolean; readonly runner?: typeof defaultRunner; readonly readFile?: (path: string) => Promise; } @@ -103,7 +168,11 @@ export class WhisperCppProvider implements TranscriptionProvider { async transcribe( audioPath: string, - opts: { readonly language?: string; readonly model?: string }, + opts: { + readonly language?: string; + readonly model?: string; + readonly onProgress?: (fraction: number) => void; + }, ): Promise<{ language: string; model: string; segments: RawSegment[] }> { // whisper-cli writes .json rather than printing to stdout. // Derived from the filename component only (node:path), not a bare regex @@ -111,17 +180,40 @@ export class WhisperCppProvider implements TranscriptionProvider { // a dot inside a directory name too, so an extension-less file inside a // directory like "ailoud-1.2" would collapse to a sibling path outside that // directory and silently collide with another recording's output. - // NOT VERIFIED AGAINST A REAL BUILD: that whisper-cli writes exactly - // ".json" (not, say, ".json.txt" or a name that - // depends on other flags) is likewise taken from documentation, not - // confirmed against a real run; see the warning on buildWhisperArgs. + // VERIFIED: whisper-cli writes exactly ".json" -- a real run + // over fixtures/en-short.wav with this argument list was read back from + // that path successfully. See buildWhisperArgs above. const outputBase = join(dirname(audioPath), basename(audioPath, extname(audioPath))); const modelPath = opts.model ?? this.options.modelPath; - const args = buildWhisperArgs(modelPath, audioPath, opts.language, outputBase); + const args = buildWhisperArgs( + modelPath, + audioPath, + opts.language, + outputBase, + this.options.threads, + this.options.gpu, + ); // Six hours, not the run helper's half-hour default: a long recording on // CPU-only whisper is genuinely slow, and the default would kill real work. - const result = await this.runner(this.options.binary, args, { timeoutMs: 6 * 60 * 60_000 }); + const result = await this.runner(this.options.binary, args, { + timeoutMs: 6 * 60 * 60_000, + ...(opts.onProgress === undefined + ? {} + : { + onStderrLine: (line) => { + const percent = parseProgressPercent(line); + if (percent === null) return; + // run() already swallows a throwing sink; this try is here so + // the guarantee holds for any future caller of the parser too. + try { + opts.onProgress?.(percent / 100); + } catch { + // An observer does not get to fail a transcription. + } + }, + }), + }); if (result.code !== 0) { throw new FailureError(`whisper failed: ${result.stderr.trim() || `exit ${result.code}`}`); @@ -149,7 +241,16 @@ export class WhisperCppProvider implements TranscriptionProvider { const modelPath = opts.model ?? this.options.modelPath; const result = await this.runner( this.options.binary, - ['-m', modelPath, '-f', audioPath, '-dl'], + [ + '-m', + modelPath, + '-f', + audioPath, + '-t', + String(this.options.threads), + ...(this.options.gpu ? [] : ['-ng']), + '-dl', + ], { timeoutMs: 10 * 60_000 }, ); if (result.code !== 0) { diff --git a/packages/providers/src/system/accelerator.test.ts b/packages/providers/src/system/accelerator.test.ts new file mode 100644 index 0000000..b207451 --- /dev/null +++ b/packages/providers/src/system/accelerator.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { parseBackends, probeBackends } from './accelerator.js'; + +/** Verbatim from `whisper-cli --help` on an M1 Pro, homebrew ggml 0.22.0. */ +const REAL_STDERR = [ + 'load_backend: loaded BLAS backend from /opt/homebrew/Cellar/ggml/0.22.0/libexec/libggml-blas.so', + 'ggml_metal_device_init: GPU name: MTL0 (Apple M1 Pro)', + 'load_backend: loaded MTL backend from /opt/homebrew/Cellar/ggml/0.22.0/libexec/libggml-metal.so', + 'load_backend: loaded CPU backend from /opt/homebrew/Cellar/ggml/0.22.0/libexec/libggml-cpu-apple_m1.so', +].join('\n'); + +describe('parseBackends', () => { + it('reads every backend a real whisper build reports', () => { + expect(parseBackends(REAL_STDERR)).toEqual(['BLAS', 'MTL', 'CPU']); + }); + + it('de-duplicates a backend named twice', () => { + const output = + 'load_backend: loaded CPU backend from a\nload_backend: loaded CPU backend from b'; + expect(parseBackends(output)).toEqual(['CPU']); + }); + + it('answers empty for output with no backend lines', () => { + expect(parseBackends('usage: some-tool [options]')).toEqual([]); + }); + + it('answers empty for empty output', () => { + expect(parseBackends('')).toEqual([]); + }); +}); + +describe('probeBackends', () => { + it('reads stdout and stderr together, because ggml prints to stderr', async () => { + // Measured: the load_backend lines arrive on stderr, before the usage + // text. A probe that read only stdout would report no backends at all. + const run = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: REAL_STDERR }); + expect(await probeBackends('whisper-cli', { run })).toEqual(['BLAS', 'MTL', 'CPU']); + }); + + it('ignores the exit code', async () => { + // Several of these binaries print usage and exit non-zero for --help. + // The output is still exactly what is wanted. + const run = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: REAL_STDERR }); + expect(await probeBackends('whisper-cli', { run })).toEqual(['BLAS', 'MTL', 'CPU']); + }); + + it('answers empty rather than throwing when the binary is missing', async () => { + const run = vi.fn().mockRejectedValue(new Error('was not found on PATH')); + await expect(probeBackends('nope', { run })).resolves.toEqual([]); + }); + + it('spawns an argument array with a timeout', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + await probeBackends('whisper-cli', { run }); + const [command, args, options] = run.mock.calls[0]!; + expect(command).toBe('whisper-cli'); + expect(args).toEqual(['--help']); + expect(options.timeoutMs).toBeGreaterThan(0); + }); +}); diff --git a/packages/providers/src/system/accelerator.ts b/packages/providers/src/system/accelerator.ts new file mode 100644 index 0000000..5499c33 --- /dev/null +++ b/packages/providers/src/system/accelerator.ts @@ -0,0 +1,68 @@ +import { run as defaultRunner } from '../process/run.js'; + +/** + * Generous for a `--help`, because a ggml build enumerates and compiles its + * Metal kernel libraries on the way to printing it -- twenty compiled + * libraries on the machine this was measured on. + */ +const PROBE_TIMEOUT_MS = 10_000; + +/** + * MEASURED, not guessed: a ggml binary announces each backend it loaded with + * a line shaped `load_backend: loaded MTL backend from `, on stderr, + * before its usage text. + */ +const BACKEND_LINE = /load_backend: loaded (\S+) backend/g; + +export function parseBackends(output: string): readonly string[] { + const found = new Set(); + for (const match of output.matchAll(BACKEND_LINE)) { + const name = match[1]; + if (name !== undefined) found.add(name.toUpperCase()); + } + return [...found]; +} + +const memo = new Map>(); + +async function read(binary: string, run: typeof defaultRunner): Promise { + try { + // An argument array, never a shell string. + const result = await run(binary, ['--help'], { timeoutMs: PROBE_TIMEOUT_MS }); + // The exit code is deliberately ignored: several of these binaries print + // usage and exit non-zero for --help, and the backend lines are already + // there either way. Both streams, because ggml writes to stderr. + return parseBackends(`${result.stdout}\n${result.stderr}`); + } catch { + // A missing binary, a permissions problem, a timeout. None of them is + // worth a thrown error: this feeds a doctor line, and an empty list reads + // as "could not tell", which is the truth. + return []; + } +} + +/** + * Which ggml backends `binary` loads -- `['BLAS', 'MTL', 'CPU']` on an Apple + * Silicon homebrew whisper build. + * + * For `doctor`'s reporting only. Nothing on the transcription path may call + * this: the answer is not used to decide a flag, because a backend that is + * loaded is not evidence that using it is faster. Measuring sherpa's CoreML + * provider found it twenty percent SLOWER than its CPU path, which is why + * this module probes and reports rather than probes and switches. + * + * Memoised per binary regardless, so a future caller cannot make this a + * per-recording subprocess. + */ +export function probeBackends( + binary: string, + deps: { readonly run?: typeof defaultRunner } = {}, +): Promise { + const run = deps.run ?? defaultRunner; + if (deps.run !== undefined) return read(binary, run); + const cached = memo.get(binary); + if (cached !== undefined) return cached; + const pending = read(binary, run); + memo.set(binary, pending); + return pending; +} diff --git a/packages/providers/src/system/cpuTopology.test.ts b/packages/providers/src/system/cpuTopology.test.ts new file mode 100644 index 0000000..ded34a5 --- /dev/null +++ b/packages/providers/src/system/cpuTopology.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; +import { cpuTopology, parsePerformanceCores } from './cpuTopology.js'; + +describe('parsePerformanceCores', () => { + it('reads the count sysctl prints', () => { + // Measured on an M1 Pro: `sysctl -n hw.perflevel0.logicalcpu` prints "8". + expect(parsePerformanceCores('8\n', 10)).toBe(8); + }); + + it.each([ + ['', 'empty output'], + ['not a number\n', 'unparseable output'], + ['0\n', 'a count below one'], + ['-3\n', 'a negative count'], + ['12\n', 'a count above the logical total'], + ['3.5\n', 'a fractional count'], + ])('answers null for %s (%s)', (stdout) => { + // Every one of these means "the split is unknown", and unknown must fall + // back to the logical count rather than produce a wrong ceiling. + expect(parsePerformanceCores(stdout, 10)).toBeNull(); + }); +}); + +describe('cpuTopology', () => { + it('reads the performance split on darwin', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, stdout: '8\n', stderr: '' }); + const topology = await cpuTopology({ platform: 'darwin', logical: () => 10, run }); + expect(topology).toEqual({ logical: 10, performance: 8 }); + const [command, args, options] = run.mock.calls[0]!; + expect(command).toBe('sysctl'); + // An argument array, never a shell string. AGENTS.md, Security Notes. + expect(args).toEqual(['-n', 'hw.perflevel0.logicalcpu']); + // Every subprocess call carries a timeout. + expect(options.timeoutMs).toBeGreaterThan(0); + }); + + it('does not run sysctl at all off darwin', async () => { + const run = vi.fn(); + const topology = await cpuTopology({ platform: 'linux', logical: () => 16, run }); + expect(topology).toEqual({ logical: 16, performance: null }); + expect(run).not.toHaveBeenCalled(); + }); + + it('answers a null split when sysctl exits non-zero', async () => { + // Intel macOS has no perflevel keys, so this is the normal path there. + const run = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'unknown oid' }); + const topology = await cpuTopology({ platform: 'darwin', logical: () => 8, run }); + expect(topology).toEqual({ logical: 8, performance: null }); + }); + + it('answers a null split when sysctl throws, without throwing itself', async () => { + // A resource hint may never be the thing that fails a transcription. + const run = vi.fn().mockRejectedValue(new Error('spawn ENOENT')); + await expect(cpuTopology({ platform: 'darwin', logical: () => 8, run })).resolves.toEqual({ + logical: 8, + performance: null, + }); + }); + + it('floors the logical count at one', async () => { + const run = vi.fn(); + const topology = await cpuTopology({ platform: 'linux', logical: () => 0, run }); + expect(topology.logical).toBe(1); + }); +}); diff --git a/packages/providers/src/system/cpuTopology.ts b/packages/providers/src/system/cpuTopology.ts new file mode 100644 index 0000000..1bc86b0 --- /dev/null +++ b/packages/providers/src/system/cpuTopology.ts @@ -0,0 +1,81 @@ +import { availableParallelism } from 'node:os'; +import type { CpuTopology } from '@ailoud/core'; +import { run as defaultRunner } from '../process/run.js'; + +/** + * Short, unlike every other timeout in this project: `sysctl -n` reads one + * kernel value and returns. Anything slower than this is a machine in trouble, + * and waiting on it would delay the start of every transcription. + */ +const SYSCTL_TIMEOUT_MS = 5_000; + +/** + * The count of performance cores, or null when the answer is not usable. + * + * `logical` is the sanity bound: a split larger than the total is a value + * this code does not understand, and guessing at it would produce a ceiling + * higher than the machine has. + */ +export function parsePerformanceCores(stdout: string, logical: number): number | null { + const trimmed = stdout.trim(); + if (trimmed === '') return null; + const value = Number(trimmed); + if (!Number.isInteger(value)) return null; + if (value < 1 || value > logical) return null; + return value; +} + +interface TopologyDeps { + readonly platform: NodeJS.Platform; + readonly logical: () => number; + readonly run: typeof defaultRunner; +} + +let memo: Promise | null = null; + +async function read(deps: TopologyDeps): Promise { + const logical = Math.max(1, Math.round(deps.logical())); + + // darwin only, on purpose. Linux reports nothing comparable that is + // reliable across kernels and vendors -- `cpu_capacity` is absent on most + // x86 kernels and `cpuinfo_max_freq` reflects boost state, not core class -- + // and a wrong split is worse than no split: it would cap the ceiling below + // what the machine can actually do, on every run, invisibly. + if (deps.platform !== 'darwin') return { logical, performance: null }; + + try { + // An argument array, never a shell string. + const result = await deps.run('sysctl', ['-n', 'hw.perflevel0.logicalcpu'], { + timeoutMs: SYSCTL_TIMEOUT_MS, + }); + if (result.code !== 0) return { logical, performance: null }; + return { logical, performance: parsePerformanceCores(result.stdout, logical) }; + } catch { + // `run` turns a missing binary into an EnvironmentError, and a timeout + // into a rejection. Neither is a reason to fail a transcription: the + // logical count is a perfectly serviceable answer. + return { logical, performance: null }; + } +} + +/** + * What this machine offers. Memoised: the topology cannot change while the + * process runs, and this sits in front of every transcription. + * + * `deps` is for tests only. Production passes nothing. + */ +export function cpuTopology(deps?: Partial): Promise { + if (deps !== undefined) { + return read({ + platform: deps.platform ?? process.platform, + logical: deps.logical ?? availableParallelism, + run: deps.run ?? defaultRunner, + }); + } + memo ??= read({ + platform: process.platform, + logical: availableParallelism, + run: defaultRunner, + }); + return memo; +} diff --git a/packages/providers/src/system/nodeFs.test.ts b/packages/providers/src/system/nodeFs.test.ts index 164758b..911d6d5 100644 --- a/packages/providers/src/system/nodeFs.test.ts +++ b/packages/providers/src/system/nodeFs.test.ts @@ -64,3 +64,33 @@ describe('NodeFs.exists / isDirectory', () => { } }); }); + +describe('NodeFs.rename', () => { + it('replaces the target and removes the source, atomically', async () => { + const fs = new NodeFs(); + const dir = mkdtempSync(join(tmpdir(), 'ailoud-test-')); + try { + const from = join(dir, 'source.tmp'); + const to = join(dir, 'target.json'); + writeFileSync(from, 'new content'); + writeFileSync(to, 'old content'); + + await fs.rename(from, to); + + expect(existsSync(from)).toBe(false); + expect(await fs.readTextFile(to)).toBe('new content'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('rejects when the source does not exist', async () => { + const fs = new NodeFs(); + const dir = mkdtempSync(join(tmpdir(), 'ailoud-test-')); + try { + await expect(fs.rename(join(dir, 'missing'), join(dir, 'target'))).rejects.toBeTruthy(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/providers/src/system/nodeFs.ts b/packages/providers/src/system/nodeFs.ts index 5b379f2..7174dfc 100644 --- a/packages/providers/src/system/nodeFs.ts +++ b/packages/providers/src/system/nodeFs.ts @@ -1,6 +1,16 @@ import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Fs, TempDir, TempFile } from '@ailoud/core'; @@ -76,4 +86,7 @@ export class NodeFs implements Fs { async readTextFile(path: string): Promise { return readFile(path, 'utf8'); } + async rename(from: string, to: string): Promise { + await rename(from, to); + } } diff --git a/packages/providers/src/update/installMethod.test.ts b/packages/providers/src/update/installMethod.test.ts new file mode 100644 index 0000000..751c9a5 --- /dev/null +++ b/packages/providers/src/update/installMethod.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; +import { detectInstallMethod, installCommandFor, sweepCommandFor } from './installMethod.js'; +import type { DetectOptions } from './installMethod.js'; + +function fakeRoots(roots: { npm?: string; pnpm?: string }): DetectOptions['run'] { + return async (command, _args) => { + const root = command === 'npm' ? roots.npm : command === 'pnpm' ? roots.pnpm : undefined; + if (root === undefined) return { code: 1, stdout: '', stderr: `${command}: command not found` }; + return { code: 0, stdout: root, stderr: '' }; + }; +} + +describe('detectInstallMethod', () => { + it('detects a global pnpm install through a symlinked root', async () => { + // pnpm's global bin is a symlink farm, so an unresolved string compare + // misses every time. This test fails on any implementation that skips + // realpath. + const method = await detectInstallMethod({ + packageRoot: '/Users/x/.local/share/pnpm/global/5/node_modules/ailoud', + execPath: '/usr/local/bin/node', + realpath: async (p) => p.replace('/link/', '/real/'), + run: fakeRoots({ + npm: '/opt/homebrew/lib/node_modules', + pnpm: '/Users/x/.local/share/pnpm/global/5/node_modules', + }), + }); + expect(method.kind).toBe('pnpm-global'); + }); + + it('refuses an npx cache entry', async () => { + // An npx cache entry also sits inside a node_modules directory, so this + // packageRoot would be mistaken for a project dependency if the + // /_npx/ check were not evaluated first. + const method = await detectInstallMethod({ + packageRoot: '/Users/x/.npm/_npx/abcd1234/node_modules/ailoud', + execPath: '/usr/local/bin/node', + realpath: async (p) => p, + run: fakeRoots({ + npm: '/opt/homebrew/lib/node_modules', + pnpm: '/Users/x/.local/share/pnpm/global/5/node_modules', + }), + }); + expect(method).toEqual({ kind: 'npx', hint: 'npx ailoud@' }); + }); + + it('refuses a project dependency and names the project', async () => { + const method = await detectInstallMethod({ + packageRoot: '/Users/x/code/some-app/node_modules/ailoud', + execPath: '/usr/local/bin/node', + realpath: async (p) => p, + run: fakeRoots({ + npm: '/opt/homebrew/lib/node_modules', + pnpm: '/Users/x/.local/share/pnpm/global/5/node_modules', + }), + }); + expect(method).toEqual({ + kind: 'project', + projectDir: '/Users/x/code/some-app', + hint: "run your package manager's add command in /Users/x/code/some-app", + }); + }); + + it('is unknown when neither manager claims the root', async () => { + const method = await detectInstallMethod({ + packageRoot: '/opt/custom/ailoud', + execPath: '/usr/local/bin/node', + realpath: async (p) => p, + run: fakeRoots({ + npm: '/opt/homebrew/lib/node_modules', + pnpm: '/Users/x/.local/share/pnpm/global/5/node_modules', + }), + }); + expect(method).toEqual({ + kind: 'unknown', + hint: 'npm install -g ailoud@, or pnpm add -g ailoud@', + }); + }); + + it('survives a package manager that is not installed', async () => { + // pnpm is not on this machine: its "root -g" rejects instead of + // answering. Detection must still resolve via npm rather than throwing. + const method = await detectInstallMethod({ + packageRoot: '/opt/homebrew/lib/node_modules/ailoud', + execPath: '/usr/local/bin/node', + realpath: async (p) => p, + run: async (command) => { + if (command === 'pnpm') throw new Error('spawn pnpm ENOENT'); + return { code: 0, stdout: '/opt/homebrew/lib/node_modules', stderr: '' }; + }, + }); + expect(method.kind).toBe('npm-global'); + }); +}); + +describe('installCommandFor', () => { + it('anchors the npm-global install beside the running node, not to a bare "npm"', () => { + // A bare 'npm' resolves off PATH, which can belong to an entirely + // different Node than the one running us (nvm/fnm/asdf/volta) -- see + // this module's own doc comment on DetectOptions.execPath for the + // machine layout that breaks under a bare name. + const command = installCommandFor( + { kind: 'npm-global' }, + '1.0.1', + '/home/x/.nvm/versions/node/v20.11.0/bin/node', + ); + expect(command).toEqual([ + '/home/x/.nvm/versions/node/v20.11.0/bin/npm', + 'install', + '-g', + 'ailoud@1.0.1', + ]); + }); + + it('keeps the pnpm-global install as a bare "pnpm", deliberately', () => { + // pnpm's global bin comes from PNPM_HOME/corepack, not from any one + // Node's install tree, so there is no execPath-equivalent anchor for it + // -- bare 'pnpm' IS the correct resolution. Asserted explicitly so + // nobody "fixes" this into a broken anchor later. + const command = installCommandFor( + { kind: 'pnpm-global' }, + '1.0.1', + '/home/x/.nvm/versions/node/v20.11.0/bin/node', + ); + expect(command).toEqual(['pnpm', 'add', '-g', 'ailoud@1.0.1']); + }); + + it('refuses to build a command for a method that cannot be updated', () => { + expect( + installCommandFor({ kind: 'npx', hint: 'npx ailoud@1.0.1' }, '1.0.1', '/usr/local/bin/node'), + ).toBeNull(); + }); + + it('names the project, not the pnpm store entry, for a pnpm-installed dependency', async () => { + // pnpm puts a project dependency at + // /node_modules/.pnpm/@/node_modules/. + // Taking the LAST `/node_modules/` named the store entry, so the hint told + // the user to run their add command in `.../.pnpm/ailoud@1.0.0`. + const method = await detectInstallMethod({ + packageRoot: '/Users/x/repo/node_modules/.pnpm/ailoud@1.0.0/node_modules/ailoud', + execPath: '/usr/local/bin/node', + realpath: async (path: string) => path, + run: async () => ({ code: 0, stdout: '/nowhere\n', stderr: '' }), + }); + expect(method).toEqual({ + kind: 'project', + projectDir: '/Users/x/repo', + hint: "run your package manager's add command in /Users/x/repo", + }); + }); + + it('detects a global install under a version manager, where PATH npm is a different Node', async () => { + // nvm, fnm, asdf and volta all do this: the npm on PATH belongs to another + // Node version, so `npm root -g` reports THAT version's root, ours never + // matches, and an ordinary global install used to read as a project + // dependency -- telling the user to run an add command inside + // `~/.nvm/versions/node/v18.20.4/lib`. + const method = await detectInstallMethod({ + execPath: '/home/x/.nvm/versions/node/v18.20.4/bin/node', + packageRoot: '/home/x/.nvm/versions/node/v18.20.4/lib/node_modules/ailoud', + realpath: async (path: string) => path, + run: async () => ({ + code: 0, + stdout: '/home/x/.nvm/versions/node/v20.11.0/lib/node_modules\n', + stderr: '', + }), + }); + expect(method).toEqual({ kind: 'npm-global' }); + }); + + it('does not mistake a sibling directory for the global root', async () => { + // `startsWith` compares characters, not path components, so + // `/usr/lib/node_modules-other` used to read as living under + // `/usr/lib/node_modules`. + const method = await detectInstallMethod({ + execPath: '/usr/bin/node', + packageRoot: '/usr/lib/node_modules-other/ailoud', + realpath: async (path: string) => path, + run: async () => ({ code: 0, stdout: '/usr/lib/node_modules\n', stderr: '' }), + }); + expect(method.kind).not.toBe('npm-global'); + }); +}); + +describe('sweepCommandFor', () => { + it('anchors the npm-global sweep beside the running node, not to a bare "ailoud"', async () => { + // Same reasoning as the install: a bare 'ailoud' resolves off PATH, which + // can be a completely different install than the one the package manager + // just wrote -- the exact staleness bug the subprocess sweep exists to + // prevent. + const command = await sweepCommandFor( + { kind: 'npm-global' }, + '/home/x/.nvm/versions/node/v20.11.0/bin/node', + async () => ({ code: 1, stdout: '', stderr: 'unused for npm-global' }), + ); + expect(command).toEqual(['/home/x/.nvm/versions/node/v20.11.0/bin/ailoud', 'self', 'sync']); + }); + + it('anchors the pnpm-global sweep to the path "pnpm bin -g" reports', async () => { + const seen: Array<{ command: string; args: readonly string[] }> = []; + const command = await sweepCommandFor( + { kind: 'pnpm-global' }, + '/usr/local/bin/node', + async (cmd, args) => { + seen.push({ command: cmd, args }); + return { code: 0, stdout: '/home/x/.local/share/pnpm\n', stderr: '' }; + }, + ); + expect(seen).toEqual([{ command: 'pnpm', args: ['bin', '-g'] }]); + expect(command).toEqual(['/home/x/.local/share/pnpm/ailoud', 'self', 'sync']); + }); + + it('answers null, rather than a guess, when "pnpm bin -g" fails', async () => { + const command = await sweepCommandFor( + { kind: 'pnpm-global' }, + '/usr/local/bin/node', + async () => ({ code: 1, stdout: '', stderr: 'pnpm: command not found' }), + ); + expect(command).toBeNull(); + }); + + it('answers null when "pnpm bin -g" throws rather than exiting non-zero', async () => { + const command = await sweepCommandFor( + { kind: 'pnpm-global' }, + '/usr/local/bin/node', + async () => { + throw new Error('spawn pnpm ENOENT'); + }, + ); + expect(command).toBeNull(); + }); + + it('answers null for a method that cannot be updated', async () => { + const command = await sweepCommandFor( + { kind: 'unknown', hint: 'npm install -g ailoud@, or pnpm add -g ailoud@' }, + '/usr/local/bin/node', + async () => ({ code: 0, stdout: '/nowhere', stderr: '' }), + ); + expect(command).toBeNull(); + }); +}); diff --git a/packages/providers/src/update/installMethod.ts b/packages/providers/src/update/installMethod.ts new file mode 100644 index 0000000..651cada --- /dev/null +++ b/packages/providers/src/update/installMethod.ts @@ -0,0 +1,195 @@ +import { dirname, join } from 'node:path'; +import type { RunResult } from '../process/run.js'; + +/** + * `hint` is the command to run by hand, and every refusing variant carries + * one: a refusal that does not say what to do instead is just a failure. + */ +export type InstallMethod = + | { readonly kind: 'npm-global' } + | { readonly kind: 'pnpm-global' } + | { readonly kind: 'npx'; readonly hint: string } + | { readonly kind: 'project'; readonly projectDir: string; readonly hint: string } + | { readonly kind: 'unknown'; readonly hint: string }; + +export interface DetectOptions { + /** + * The Node binary running this process, i.e. `process.execPath`. + * + * Used to locate the global `node_modules` of the Node that is RUNNING us, + * which is the one ailoud was installed into. `npm root -g` cannot answer + * that on its own: under nvm, fnm, asdf and volta the npm on PATH often + * belongs to a different Node version, so it reports that version's root, + * our own root never matches it, and an ordinary global install is then + * misread as a project dependency -- with a hint telling the user to run an + * add command inside `~/.nvm/versions/node/v18.20.4/lib`. + */ + readonly execPath: string; + /** Where the installed package sits, resolved from `import.meta.url`. */ + readonly packageRoot: string; + readonly realpath: (path: string) => Promise; + readonly run: (command: string, args: readonly string[]) => Promise; +} + +export async function detectInstallMethod(options: DetectOptions): Promise { + const root = await options.realpath(options.packageRoot); + + // FIRST. An npx cache entry also sits inside a `node_modules`, so a + // node_modules check placed ahead of this one reports it as a project + // dependency and prints the wrong command. + if (root.includes('/_npx/')) return { kind: 'npx', hint: 'npx ailoud@' }; + + // Both managers, in parallel, each tolerating "not installed". `npm root -g` + // and `pnpm root -g` print the global node_modules. + const [npmRoot, pnpmRoot] = await Promise.all([ + globalRoot(options, 'npm'), + globalRoot(options, 'pnpm'), + ]); + if (npmRoot !== null && isUnder(root, npmRoot)) return { kind: 'npm-global' }; + if (pnpmRoot !== null && isUnder(root, pnpmRoot)) return { kind: 'pnpm-global' }; + + // The global root of the Node running us, checked after the managers and + + // before the project fallback: it is the layout npm itself uses + + // (`/lib/node_modules`), and it is version-correct by + + // construction because it comes from our own interpreter. + + const ownRoot = await options + .realpath(`${dirname(dirname(options.execPath))}/lib/node_modules`) + .catch(() => null); + + if (ownRoot !== null && isUnder(root, ownRoot)) return { kind: 'npm-global' }; + + // Whatever is left inside a node_modules is somebody's dependency. The + // FIRST `/node_modules/` is the project boundary, not the last: pnpm installs + // a project dependency at + // `/node_modules/.pnpm/ailoud@1.0.0/node_modules/ailoud`, so taking + // the last one named the store entry and told the user to run their add + // command in `.../node_modules/.pnpm/ailoud@1.0.0`. A nested dependency of a + // dependency lands on the project for the same reason, which is also right. + const marker = root.indexOf('/node_modules/'); + if (marker !== -1) { + const projectDir = root.slice(0, marker); + return { + kind: 'project', + projectDir, + hint: `run your package manager's add command in ${projectDir}`, + }; + } + return { + kind: 'unknown', + hint: 'npm install -g ailoud@, or pnpm add -g ailoud@', + }; +} + +async function globalRoot(options: DetectOptions, manager: string): Promise { + try { + const result = await options.run(manager, ['root', '-g']); + if (result.code !== 0) return null; + // realpath both sides: pnpm's global tree is a symlink farm, and an + // unresolved string compare misses every time. + return await options.realpath(result.stdout.trim()); + } catch { + return null; // that manager is not on this machine + } +} + +/** + * The argv for an update, or null when this install method cannot be updated. + * + * `npm-global` is anchored to `join(dirname(execPath), 'npm')` rather than + * bare `'npm'`: every npm global install lays its bins out beside `node` + * itself, so this is the one npm that is GUARANTEED to belong to the Node + * that is running us, unlike a bare name, which PATH resolves and which can + * belong to an entirely different Node under nvm/fnm/asdf/volta (see + * `DetectOptions.execPath`'s own doc comment, and `self.ts`'s doc comment on + * `updateSelf`, for the concrete machine layout that breaks under a bare + * name). + * + * `pnpm-global` deliberately stays bare `'pnpm'`: pnpm's global bin comes + * from `PNPM_HOME`/corepack, not from any one Node's install tree, so there + * is no execPath-equivalent anchor for it, and bare `pnpm` IS the correct + * resolution here. Do not "fix" this into an anchored path -- there is + * nothing to anchor it to. + */ +export function installCommandFor( + method: InstallMethod, + target: string, + execPath: string, +): readonly string[] | null { + switch (method.kind) { + case 'npm-global': + return [join(dirname(execPath), 'npm'), 'install', '-g', `ailoud@${target}`]; + case 'pnpm-global': + return ['pnpm', 'add', '-g', `ailoud@${target}`]; + case 'npx': + case 'project': + case 'unknown': + return null; + } +} + +/** + * The argv for the subprocess that re-syncs rules after a successful + * install, or null when it cannot be determined -- the caller then prints + * the command for the user to run by hand rather than guessing. + * + * Anchored the same way `installCommandFor` anchors the install itself, and + * for the same reason: a bare `ailoud` resolved off PATH can be an entirely + * different install than the one the package manager just wrote. + * + * `npm-global`: the new binary sits beside `npm` and `node` in the same bin + * directory by construction, so `dirname(execPath)` anchors it exactly like + * the install command above. + * + * `pnpm-global`: there is no execPath-equivalent anchor, so this asks pnpm + * itself where its global bin lives (`pnpm bin -g`), through the same `run` + * detection uses -- bounded to the same 10 second timeout in production + * (`boundedDetectRun` in `self.ts`). Null when that query fails or answers + * nothing, rather than guessing at a bare `ailoud`. + */ +export async function sweepCommandFor( + method: InstallMethod, + execPath: string, + run: (command: string, args: readonly string[]) => Promise, +): Promise { + switch (method.kind) { + case 'npm-global': + return [join(dirname(execPath), 'ailoud'), 'self', 'sync']; + case 'pnpm-global': { + const bin = await pnpmGlobalBin(run); + return bin === null ? null : [join(bin, 'ailoud'), 'self', 'sync']; + } + case 'npx': + case 'project': + case 'unknown': + return null; + } +} + +async function pnpmGlobalBin( + run: (command: string, args: readonly string[]) => Promise, +): Promise { + try { + const result = await run('pnpm', ['bin', '-g']); + if (result.code !== 0) return null; + const bin = result.stdout.trim(); + return bin === '' ? null : bin; + } catch { + return null; + } +} + +/** + * Whether `path` sits inside `directory`. + * + * The separator is required, so `/usr/lib/node_modules-other/ailoud` does not + * read as living under `/usr/lib/node_modules`. A bare `startsWith` compares + * characters, not path components. + */ +function isUnder(path: string, directory: string): boolean { + const base = directory.endsWith('/') ? directory : `${directory}/`; + return path.startsWith(base); +} diff --git a/packages/providers/src/update/npmRegistry.test.ts b/packages/providers/src/update/npmRegistry.test.ts new file mode 100644 index 0000000..dba18f0 --- /dev/null +++ b/packages/providers/src/update/npmRegistry.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NpmRegistry, isDeprecated } from './npmRegistry.js'; +import type { RegistryTransport } from './npmRegistry.js'; + +/** A transport that answers with a fixed status and body, recording its calls. */ +const answering = (status: number, body: string) => + vi.fn(async () => ({ status, body })); + +const packument = { + name: 'ailoud', + versions: { + '1.0.0-dev.1': { name: 'ailoud', version: '1.0.0-dev.1', deprecated: 'superseded by 1.0.0' }, + '1.0.0': { name: 'ailoud', version: '1.0.0' }, + }, +}; + +describe('NpmRegistry', () => { + it('reports every version, marking the deprecated ones', async () => { + const transport = answering(200, JSON.stringify(packument)); + const registry = new NpmRegistry({ transport }); + expect(await registry.published('ailoud')).toEqual([ + { version: '1.0.0-dev.1', deprecated: true }, + { version: '1.0.0', deprecated: false }, + ]); + }); + + it('asks for the abbreviated packument', async () => { + const transport = answering(200, JSON.stringify(packument)); + await new NpmRegistry({ transport }).published('ailoud'); + const [, headers] = transport.mock.calls[0]!; + expect(headers).toMatchObject({ accept: 'application/vnd.npm.install-v1+json' }); + }); + + it('escapes a scoped name', async () => { + const transport = answering(200, JSON.stringify(packument)); + await new NpmRegistry({ transport }).published('@ailoud/core'); + expect(transport.mock.calls[0]![0]).toBe('https://registry.npmjs.org/@ailoud%2fcore'); + }); + + it('throws with the status when the registry refuses', async () => { + const transport = answering(503, 'nope'); + await expect(new NpmRegistry({ transport }).published('ailoud')).rejects.toThrow(/503/); + }); + + it('throws when the versions object is present but empty', async () => { + // A 200 with `{"versions": {}}` used to resolve to an empty list, which + // every caller reads as "nothing newer exists". A package with no versions + // cannot be the one we are running. + const transport = answering(200, '{"versions":{}}'); + await expect(new NpmRegistry({ transport }).published('ailoud')).rejects.toThrow(/no versions/); + }); + + it('treats an empty deprecation message as not deprecated', async () => { + // `npm deprecate @ ""` un-deprecates by setting an empty + // string, not by removing the field. Testing for the key rather than the + // value reported a revived version as still deprecated, which would refuse + // a legitimate update. + const body = JSON.stringify({ + versions: { + '1.0.0': { version: '1.0.0', deprecated: '' }, + '1.0.1': { version: '1.0.1', deprecated: 'do not use' }, + }, + }); + const transport = answering(200, body); + expect(await new NpmRegistry({ transport }).published('ailoud')).toEqual([ + { version: '1.0.0', deprecated: false }, + { version: '1.0.1', deprecated: true }, + ]); + }); + + it('throws when the body has no versions', async () => { + // A silent empty answer would read as "you are up to date", which is the + // one wrong thing a version check can say. + const transport = answering(200, '{}'); + await expect(new NpmRegistry({ transport }).published('ailoud')).rejects.toThrow(/versions/); + }); +}); + +describe('isDeprecated', () => { + // The single rule both `packages/providers/src/update/npmRegistry.ts` and + // `apps/cli/src/updateNotice.ts` import from here -- so this file is what + // keeps the two call sites from drifting apart again, the way they did + // before this rule had one home. + it('treats an empty deprecation message as not deprecated', () => { + // `npm deprecate @ ""` un-deprecates by setting an empty + // string rather than removing the field. Testing the key's presence + // rather than the value reports a revived version as still deprecated. + expect(isDeprecated({ deprecated: '' })).toBe(false); + }); + + it('treats a non-empty deprecation message as deprecated', () => { + expect(isDeprecated({ deprecated: 'do not use' })).toBe(true); + }); + + it('treats a boolean true as deprecated', () => { + expect(isDeprecated({ deprecated: true })).toBe(true); + }); + + it('treats a missing field as not deprecated', () => { + expect(isDeprecated({})).toBe(false); + }); + + it('treats a non-object entry as not deprecated', () => { + expect(isDeprecated(null)).toBe(false); + expect(isDeprecated('nope')).toBe(false); + }); +}); diff --git a/packages/providers/src/update/npmRegistry.ts b/packages/providers/src/update/npmRegistry.ts new file mode 100644 index 0000000..55946db --- /dev/null +++ b/packages/providers/src/update/npmRegistry.ts @@ -0,0 +1,143 @@ +import { request as httpsRequest } from 'node:https'; +import type { PublishedVersion, VersionSource } from '@ailoud/core'; +import { FailureError } from '@ailoud/core'; + +/** + * Exported so callers report the same host and wait that this class would use + * by default. Two copies of these numbers drift, and then `self check` names a + * timeout the registry client never applied. + */ +export const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; +export const DEFAULT_TIMEOUT_MS = 10_000; + +const REGISTRY = DEFAULT_REGISTRY; +const TIMEOUT_MS = DEFAULT_TIMEOUT_MS; + +/** + * Fetches one URL and reports its status and body text. + * + * A seam rather than `fetch` itself, for a measured reason: aborting a `fetch` + * does NOT release the socket, so a process that gives up on a request still + * waits about 10.5 seconds to exit (Node 24, measured against an + * unresponsive address). `https.request`'s native `signal` destroys the + * socket at once and the process exits in about 60ms. The background update + * check must abandon a request the instant a command is ready to finish, so + * that difference decides the implementation -- and it is the whole reason + * this class, rather than a second hand-rolled client, can serve both callers. + */ +export type RegistryTransport = ( + url: string, + headers: Record, + signal: AbortSignal, +) => Promise<{ readonly status: number; readonly body: string }>; + +export interface NpmRegistryOptions { + readonly registry?: string; + readonly timeoutMs?: number; + /** Injected in tests, so a unit test never opens a socket. */ + readonly transport?: RegistryTransport; +} + +const httpsTransport: RegistryTransport = (url, headers, signal) => + new Promise((resolve, reject) => { + const request = httpsRequest(url, { headers, signal }, (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => + resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }), + ); + }); + request.on('error', reject); + request.end(); + }); + +/** + * Which versions of a package exist, read from the npm registry. + * + * Only the abbreviated packument is requested (the `accept` header below), + * which still carries every version's `deprecated` flag -- the field the + * whole update policy turns on -- at a fraction of the size of the full + * document. + */ +export class NpmRegistry implements VersionSource { + private readonly registry: string; + private readonly timeoutMs: number; + private readonly transport: RegistryTransport; + + constructor(options: NpmRegistryOptions = {}) { + this.registry = options.registry ?? REGISTRY; + this.timeoutMs = options.timeoutMs ?? TIMEOUT_MS; + this.transport = options.transport ?? httpsTransport; + } + + async published(packageName: string, signal?: AbortSignal): Promise { + // Same escaping npm itself uses: the slash in a scoped name would + // otherwise be a path separator. + const url = `${this.registry}/${packageName.replaceAll('/', '%2f')}`; + // The caller's cancellation AND our own timeout: whichever fires first + // wins. A caller that passes no signal still gets the timeout. + const deadline = AbortSignal.timeout(this.timeoutMs); + const combined = signal === undefined ? deadline : AbortSignal.any([signal, deadline]); + const response = await this.transport( + url, + { accept: 'application/vnd.npm.install-v1+json' }, + combined, + ); + if (response.status < 200 || response.status >= 300) { + throw new FailureError( + `the npm registry answered ${response.status} for ${packageName}, so ailoud cannot tell which versions exist.`, + ); + } + const body: unknown = JSON.parse(response.body); + const versions = + typeof body === 'object' && body !== null + ? (body as { versions?: unknown }).versions + : undefined; + if (typeof versions !== 'object' || versions === null) { + throw new FailureError( + `the npm registry returned no versions for ${packageName}, so ailoud cannot tell which versions exist.`, + ); + } + const published = Object.entries(versions as Record).map( + ([version, entry]) => ({ version, deprecated: isDeprecated(entry) }), + ); + // An empty list is not an answer. `{"versions": {}}` with a 200 would + // otherwise resolve to [], which every caller reads as "nothing newer + // exists" -- the one wrong thing a version check can say. A package that + // really has no versions cannot be the one we are running. + if (published.length === 0) { + throw new FailureError( + `the npm registry listed no versions of ${packageName}, so ailoud cannot tell which versions exist.`, + ); + } + return published; + } +} + +/** + * Whether the registry says this version is deprecated. + * + * The value matters, not the key. npm stores the deprecation MESSAGE here, and + * `npm deprecate @ ""` un-deprecates by setting an empty string + * rather than removing the field. Testing `'deprecated' in entry` therefore + * reports a revived version as still deprecated, which would refuse a + * legitimate update and, if a registry emitted the empty form widely, refuse + * every update. + * + * It lives HERE, in the provider, rather than in the domain: it decodes one + * field of npm's packument wire format, which is a provider's business and + * not a rule about versions. `apps/cli` reaches it through this package, so + * nothing needs a copy. + * + * `scripts/retire-prereleases.mjs` answers a related question with its own + * truthiness test, deliberately, and says why there. It is NOT importing this + * function, so do not describe this as the only copy -- an earlier version of + * this comment did, which made it false. + */ +export function isDeprecated(entry: unknown): boolean { + if (typeof entry !== 'object' || entry === null) return false; + const flag: unknown = (entry as { deprecated?: unknown }).deprecated; + if (typeof flag === 'string') return flag.length > 0; + // Not a shape npm documents, but a boolean true is unambiguous if it appears. + return flag === true; +} diff --git a/packages/providers/src/vad/whisperVad.test.ts b/packages/providers/src/vad/whisperVad.test.ts index fc05d3a..4b11f88 100644 --- a/packages/providers/src/vad/whisperVad.test.ts +++ b/packages/providers/src/vad/whisperVad.test.ts @@ -41,6 +41,7 @@ describe('WhisperVadSegmenter', () => { const segmenter = new WhisperVadSegmenter({ binary: 'whisper-vad-speech-segments', vadModelPath: '/models/vad.bin', + threads: 4, runner, }); @@ -48,7 +49,7 @@ describe('WhisperVadSegmenter', () => { expect(runner).toHaveBeenCalledWith( 'whisper-vad-speech-segments', - ['-f', '/tmp/a.wav', '-vm', '/models/vad.bin', '-np'], + ['-f', '/tmp/a.wav', '-vm', '/models/vad.bin', '-t', '4', '-np'], expect.anything(), ); expect(spans).toEqual([{ startMs: 0, endMs: 3460 }]); @@ -58,6 +59,7 @@ describe('WhisperVadSegmenter', () => { const segmenter = new WhisperVadSegmenter({ binary: 'whisper-vad-speech-segments', vadModelPath: '/models/vad.bin', + threads: 4, runner: async () => ({ code: 1, stdout: '', stderr: 'could not load vad model' }), }); await expect(segmenter.segments('/tmp/a.wav')).rejects.toThrow(/could not load vad model/); @@ -67,6 +69,7 @@ describe('WhisperVadSegmenter', () => { const segmenter = new WhisperVadSegmenter({ binary: 'whisper-vad-speech-segments', vadModelPath: '/models/vad.bin', + threads: 4, runner: async () => ({ code: 0, stdout: 'Detected 0 speech segments:', stderr: '' }), }); await expect(segmenter.segments('/tmp/ailoud-xK9p2/audio.wav')).rejects.toThrow( @@ -74,3 +77,43 @@ describe('WhisperVadSegmenter', () => { ); }); }); + +describe('resource flags', () => { + it('passes the thread count it was given', async () => { + const runner = vi.fn().mockResolvedValue({ + code: 0, + stdout: 'Speech segment 0: start = 0.00, end = 100.00', + stderr: '', + }); + const segmenter = new WhisperVadSegmenter({ + binary: 'whisper-vad-speech-segments', + vadModelPath: '/vad.bin', + threads: 7, + runner, + }); + await segmenter.segments('/a.wav'); + expect(runner.mock.calls[0]![1]).toEqual(expect.arrayContaining(['-t', '7'])); + }); + + it('never passes a GPU flag, because the one this binary has aborts', async () => { + // The binary does have one: `-ug, --use-gpu [false]`, opt-in rather than + // whisper-cli's opt-out `-ng`/`--no-gpu`. MEASURED that passing it aborts + // the process (exit 134, SIGABRT, zero segments on stdout), so it is + // never passed and `resources.gpu` has no effect on segmentation. + const runner = vi.fn().mockResolvedValue({ + code: 0, + stdout: 'Speech segment 0: start = 0.00, end = 100.00', + stderr: '', + }); + const segmenter = new WhisperVadSegmenter({ + binary: 'whisper-vad-speech-segments', + vadModelPath: '/vad.bin', + threads: 7, + runner, + }); + await segmenter.segments('/a.wav'); + const args = runner.mock.calls[0]![1] as string[]; + expect(args).not.toContain('-ng'); + expect(args).not.toContain('--no-gpu'); + }); +}); diff --git a/packages/providers/src/vad/whisperVad.ts b/packages/providers/src/vad/whisperVad.ts index 533c13d..f2f50c5 100644 --- a/packages/providers/src/vad/whisperVad.ts +++ b/packages/providers/src/vad/whisperVad.ts @@ -39,6 +39,11 @@ export function parseVadSegments(output: string): SpeechSpan[] { export interface WhisperVadOptions { readonly binary: string; readonly vadModelPath: string; + /** + * Threads for the segmentation pass. Required, like whisper-cli's: this + * binary also defaults to 4 whatever the machine has. + */ + readonly threads: number; readonly runner?: typeof defaultRunner; } @@ -50,9 +55,25 @@ export class WhisperVadSegmenter implements SpeechSegmenter { } public async segments(audioPath: string): Promise { + // No GPU flag passed, and not because this binary lacks one: it has + // `-ug, --use-gpu [false]`, spelled opt-in rather than whisper-cli's + // opt-out `-ng`/`--no-gpu` -- which is why grepping its --help for the + // opt-out spelling finds nothing and looks like proof of absence. MEASURED + // that `-ug` aborts: `exit=134` (SIGABRT), `ggml_abort`, zero segments on + // stdout. So `resources.gpu` has no effect on segmentation in either + // direction, on purpose: the flag exists, but passing it would hard-crash + // every multilingual transcription on this machine. const result = await this.runner( this.options.binary, - ['-f', audioPath, '-vm', this.options.vadModelPath, '-np'], + [ + '-f', + audioPath, + '-vm', + this.options.vadModelPath, + '-t', + String(this.options.threads), + '-np', + ], { timeoutMs: VAD_TIMEOUT_MS }, ); if (result.code !== 0) { diff --git a/scripts/check-changelog.mjs b/scripts/check-changelog.mjs new file mode 100755 index 0000000..c452029 --- /dev/null +++ b/scripts/check-changelog.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Verify CHANGES.md is fit to release a given tag. +// +// Usage: node scripts/check-changelog.mjs +// The tag falls back to $GITHUB_REF_NAME; a single leading `v` is stripped. +// +// Run on every tag before anything is published. A version number can never +// be reused and the unpublish window is 72 hours, so the changelog is worth +// checking while refusing is still free. +import { + HARD_LIMIT, + SOFT_LIMIT, + baseVersion, + countBullets, + escapeForRegExp, + fail, + isPrerelease, + readChanges, + splitSections, + versionFromTag, + versionHeading, + warn, +} from './lib/changelog.mjs'; + +const SCOPE = 'check-changelog'; + +const rawTag = process.argv[2] ?? process.env.GITHUB_REF_NAME; +if (!rawTag) fail(SCOPE, 'no tag given (pass one, or set $GITHUB_REF_NAME)'); + +const version = versionFromTag(rawTag); +const { sections } = splitSections(readChanges()); + +// Collected rather than thrown one at a time: someone fixing a changelog +// before a release wants the whole list, not one round trip per problem. +const problems = []; + +const own = sections.find((section) => versionHeading(version).test(section.heading)); +if (own === undefined) { + problems.push(`no "## Version ${version}" section. Promote it with bump-version first.`); +} + +const entries = own === undefined ? 0 : countBullets(own.body); +if (own !== undefined && entries === 0) { + problems.push(`the "## Version ${version}" section has no entries.`); +} +if (entries > HARD_LIMIT) { + problems.push( + `${entries} entries in this version; the hard limit is ${HARD_LIMIT}. Merge or cut some.`, + ); +} + +// A final tag must not leave its own pre-release sections behind: they +// describe the same release, and a reader of 1.0.0's notes should not have to +// read 1.0.0-dev.1's as well. fold-prereleases exists to merge them. +if (!isPrerelease(version)) { + const base = baseVersion(version); + const leftovers = sections + .map((section) => section.heading) + .filter((heading) => new RegExp(`^## Version ${escapeForRegExp(base)}-`).test(heading)); + if (leftovers.length > 0) { + problems.push( + `${leftovers.length} pre-release section(s) for ${base} are still present ` + + `(${leftovers.map((h) => h.replace('## Version ', '')).join(', ')}). ` + + `Run: node scripts/fold-prereleases.mjs ${base}`, + ); + } +} + +// Nothing may be stranded in Development at release time: an entry left there +// is a change that shipped and went unmentioned. +const development = sections.find((section) => /^## Development$/.test(section.heading)); +if (development !== undefined) { + const stranded = countBullets(development.body); + if (stranded > 0) { + problems.push( + `${stranded} entr${stranded === 1 ? 'y is' : 'ies are'} still under "## Development".`, + ); + } +} + +if (problems.length > 0) { + for (const problem of problems) console.error(`${SCOPE}: ${problem}`); + process.exit(1); +} + +if (entries > SOFT_LIMIT) { + warn(`${SCOPE}: ${entries} entries, over the soft limit of ${SOFT_LIMIT}.`); +} +console.log(`${SCOPE}: ${version} is ready (${entries} entries).`); diff --git a/scripts/check-changelog.test.mjs b/scripts/check-changelog.test.mjs new file mode 100644 index 0000000..83e418e --- /dev/null +++ b/scripts/check-changelog.test.mjs @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { changes, entries, makeSandbox, run, useSandboxes } from './testing/harness.mjs'; + +useSandboxes(); + +describe('check-changelog', () => { + it('passes a version with entries and nothing stranded', () => { + const dir = makeSandbox(changes(`## Development\n\n## Version 1.0.0\n\n### Added\n\n- One.\n`)); + const result = run(dir, 'check-changelog.mjs', ['v1.0.0']); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/1\.0\.0 is ready \(1 entries\)/); + }); + + it('refuses a version with no section', () => { + const dir = makeSandbox(changes('## Development\n\n- Something.\n')); + const result = run(dir, 'check-changelog.mjs', ['v9.9.9']); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/no "## Version 9\.9\.9" section/); + }); + + it('refuses a section with no entries', () => { + const dir = makeSandbox(changes('## Development\n\n## Version 1.0.0\n\n### Added\n')); + expect(run(dir, 'check-changelog.mjs', ['1.0.0']).stderr).toMatch(/has no entries/); + }); + + it('refuses entries left stranded under Development', () => { + // An entry left there is a change that shipped and went unmentioned. + const dir = makeSandbox( + changes('## Development\n\n- Forgotten.\n\n## Version 1.0.0\n\n- One.\n'), + ); + const result = run(dir, 'check-changelog.mjs', ['1.0.0']); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/still under "## Development"/); + }); + + it('refuses a final tag whose pre-release sections were never folded', () => { + const dir = makeSandbox( + changes( + '## Development\n\n## Version 1.0.0\n\n- One.\n\n## Version 1.0.0-dev.1\n\n- Older.\n', + ), + ); + const result = run(dir, 'check-changelog.mjs', ['1.0.0']); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/pre-release section\(s\) for 1\.0\.0 are still present/); + expect(result.stderr).toMatch(/fold-prereleases\.mjs 1\.0\.0/); + }); + + it('allows a PRE-RELEASE tag to coexist with its siblings', () => { + // Only a final tag has to have folded them. + const dir = makeSandbox( + changes( + '## Development\n\n## Version 1.0.0-dev.2\n\n- Two.\n\n## Version 1.0.0-dev.1\n\n- One.\n', + ), + ); + expect(run(dir, 'check-changelog.mjs', ['v1.0.0-dev.2']).code).toBe(0); + }); + + it('reports every problem at once, not one per run', () => { + const dir = makeSandbox(changes('## Development\n\n- Stranded.\n')); + const result = run(dir, 'check-changelog.mjs', ['1.0.0']); + expect(result.stderr).toMatch(/no "## Version 1\.0\.0" section/); + expect(result.stderr).toMatch(/still under "## Development"/); + }); + + it('refuses past the hard limit and only warns past the soft one', () => { + const over = makeSandbox(changes(`## Development\n\n## Version 1.0.0\n\n${entries(51)}\n`)); + const hard = run(over, 'check-changelog.mjs', ['1.0.0']); + expect(hard.code).toBe(1); + expect(hard.stderr).toMatch(/hard limit is 50/); + + const soft = makeSandbox(changes(`## Development\n\n## Version 1.0.0\n\n${entries(12)}\n`)); + const result = run(soft, 'check-changelog.mjs', ['1.0.0']); + expect(result.code).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/soft limit of 10/); + }); + + it('refuses with no tag at all', () => { + const dir = makeSandbox(changes('## Development\n')); + expect(run(dir, 'check-changelog.mjs').stderr).toMatch(/no tag given/); + }); + + it('falls back to $GITHUB_REF_NAME, which is how the workflow calls it', () => { + const dir = makeSandbox(changes('## Development\n\n## Version 1.0.0\n\n- One.\n')); + const result = run(dir, 'check-changelog.mjs', [], { env: { GITHUB_REF_NAME: 'v1.0.0' } }); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/1\.0\.0 is ready/); + }); + + it('warns as a GitHub annotation when it runs on a runner', () => { + // ::warning:: goes to stdout and shows up on the run summary; the plain + // warning goes to stderr. Both paths are exercised because CI once saw + // one of them and no test ever had. + const body = `## Development\n\n## Version 1.0.0\n\n${entries(12)}\n`; + const annotated = run(makeSandbox(body), 'check-changelog.mjs', ['1.0.0'], { + env: { GITHUB_ACTIONS: 'true' }, + }); + expect(annotated.stdout).toMatch(/::warning::.*soft limit of 10/); + expect(annotated.stderr).toBe(''); + + const plain = run(makeSandbox(body), 'check-changelog.mjs', ['1.0.0']); + expect(plain.stderr).toMatch(/soft limit of 10/); + expect(plain.stdout).not.toContain('::warning::'); + }); +}); diff --git a/scripts/check-dependency-age.mjs b/scripts/check-dependency-age.mjs new file mode 100644 index 0000000..d02f43a --- /dev/null +++ b/scripts/check-dependency-age.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// Refuse dependency versions that are too fresh to trust. +// +// Usage: node scripts/check-dependency-age.mjs [--days 14] +// +// WHY +// +// A compromised release is discovered by other people, and that takes days: +// the npm supply-chain incidents of recent years were all caught within a week +// or two of publication, after the malicious version had already been installed +// by everyone who upgraded immediately. Waiting is the whole mitigation, and it +// costs nothing -- there is no urgency in a patch that has been out for two +// weeks that was not there on day one. +// +// Checked against the version each manifest PINS, not what is installed: this +// project pins exact versions, so the manifest is the decision and the lockfile +// only records it. A spec that is not an exact version is reported rather than +// aged, because a range means the decision was left to whatever resolved last. +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { escapePackageName } from './lib/npmOidc.mjs'; +import { + DEFAULT_DAYS, + EXACT, + classify, + collectDependencies, + staleExceptions, +} from './lib/dependencyAge.mjs'; + +const SCOPE = 'check-dependency-age'; +const REGISTRY = 'https://registry.npmjs.org'; +const EXCEPTIONS = 'scripts/dependency-age-exceptions.json'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (path) => readFileSync(join(root, path), 'utf8'); + +/** + * When `version` was published, or null if the registry does not know. + * + * A 404 means the registry has no such package, which is a real answer. Any + * other refusal -- a 429, a 502 -- is the registry declining to answer, and + * returning null for it turned this whole check into a no-op that reported + * success: every entry became "unknown", nothing was young, exit 0. So those + * throw, and the rule fails closed. + */ +async function publishedAt(name, version) { + const response = await fetch(`${REGISTRY}/${escapePackageName(name)}`); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`the registry answered ${response.status} for ${name}; cannot judge its age`); + } + const { time } = await response.json(); + const stamp = time?.[version]; + if (typeof stamp !== 'string') return null; + const parsed = Date.parse(stamp); + // An unparseable timestamp is not an age. Reported as unknown rather than + // compared as NaN, which produced "published NaN days ago". + return Number.isFinite(parsed) ? parsed : null; +} + +const daysFlag = process.argv.indexOf('--days'); +const days = daysFlag === -1 ? DEFAULT_DAYS : Number(process.argv[daysFlag + 1]); +if (!Number.isFinite(days) || days < 0) { + console.error(`${SCOPE}: --days needs a non-negative number`); + process.exit(1); +} + +const exceptions = existsSync(join(root, EXCEPTIONS)) ? JSON.parse(read(EXCEPTIONS)) : {}; +const dependencies = collectDependencies(read); +const unpinned = [...dependencies].filter(([, spec]) => !EXACT.test(spec)); +const pinned = [...dependencies].filter(([, spec]) => EXACT.test(spec)); + +const entries = await Promise.all( + pinned.map(async ([name, spec]) => [name, spec, await publishedAt(name, spec)]), +); +const now = Date.now(); +const { young, exempt, unknown } = classify(entries, now, days, exceptions); + +console.log(`${SCOPE}: ${pinned.length} pinned direct dependencies, minimum age ${days} days`); + +for (const [name, spec] of unpinned) { + console.log(` ${name}@${spec} is not an exact version; its age was not checked`); +} +for (const { name, spec } of unknown) { + console.log(` ${name}@${spec}: the registry reported no publish time`); +} +for (const { name, spec, ageDays, reason } of exempt) { + console.log(` ${name}@${spec} is ${ageDays.toFixed(1)} days old, allowed: ${reason}`); +} +for (const key of staleExceptions(exceptions, entries, now, days)) { + // Not a failure: a hole that has closed is only clutter. Left unreported it + // would still be there the next time something needed exempting. + console.log(` ${EXCEPTIONS} no longer needs its entry for ${key}`); +} + +if (young.length === 0) { + console.log(`${SCOPE}: nothing newer than ${days} days.`); + process.exit(0); +} + +for (const { name, spec, ageDays } of young) { + console.error(`${SCOPE}: ${name}@${spec} was published ${ageDays.toFixed(1)} days ago`); +} +console.error( + `${SCOPE}: wait until each is ${days} days old, or pin the previous version. ` + + 'A compromised release is found by other people, and that takes days. ' + + `If one fixes a critical advisory, add it to ${EXCEPTIONS} with the advisory ID.`, +); +process.exit(1); diff --git a/scripts/check-docs-render.mjs b/scripts/check-docs-render.mjs new file mode 100644 index 0000000..b81d0c4 --- /dev/null +++ b/scripts/check-docs-render.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Fail the build on a documentation page that RENDERED wrong, not one that +// failed to build. +// +// Usage: +// pnpm docs:build && node scripts/check-docs-render.mjs +// +// `mkdocs build --strict` checks the nav, internal links and cross-references, +// and stays green as long as every piece of markdown is valid -- it has no +// idea what the page looks like once Material has rendered it. A `!!! note` +// block whose content is not indented four spaces is valid markdown that +// means something other than its author intended: Material renders an empty +// box, and the note's own text falls out below it as an ordinary paragraph. +// That shipped in this repository once. This script reads the built HTML +// under site/ and looks for the textual symptom each of these leaves behind: +// +// - an admonition div holding nothing but its title +// - a table's header row surviving as literal text, its separator missing +// - a stray triple backtick, left by a code fence closed in the wrong place +// - a "#Heading" run that never became a real heading +// +// See scripts/lib/checkDocsRender.mjs for how each is detected and why. +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { checkSite } from './lib/checkDocsRender.mjs'; + +const SCOPE = 'check-docs-render'; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +const { filesChecked, counts, failures } = checkSite(ROOT); + +if (filesChecked === 0) { + console.error(`${SCOPE}: no HTML under site/ -- run \`pnpm docs:build\` first.`); + process.exit(1); +} + +console.log( + `${SCOPE}: checked ${filesChecked} page(s) -- ` + + `${counts.admonitions} admonition(s), ${counts.tables} table(s), ` + + `${counts.codeBlocks} code block(s), ${counts.headings} heading(s).`, +); + +if (failures.length > 0) { + for (const { file, kind, snippet } of failures) { + console.error(`${SCOPE}: [${kind}] ${file}`); + console.error(`${SCOPE}: ${snippet}`); + } + console.error( + `${SCOPE}: ${failures.length} rendering problem(s) found. ` + + `The build stayed green; the page did not render as written.`, + ); + process.exit(1); +} + +console.log(`${SCOPE}: clean -- every check found what it should have.`); diff --git a/scripts/check-docs-render.test.mjs b/scripts/check-docs-render.test.mjs new file mode 100644 index 0000000..5d2ec7e --- /dev/null +++ b/scripts/check-docs-render.test.mjs @@ -0,0 +1,76 @@ +// This is the thin-wrapper test: it only checks that the CLI finds site/, +// reports counts, and exits non-zero with the offending file named. The +// detection rules themselves are covered in scripts/lib/checkDocsRender.test.mjs, +// against the pure functions directly, not by spawning a process per case. +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { REPO, run } from './testing/harness.mjs'; + +const made = []; +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A throwaway repository holding only the scripts and a fixture site/ tree. + * + * check-docs-render.mjs resolves site/ relative to its own file location, not + * the working directory, so copying scripts/ next to a fixture tree puts it + * somewhere with content we control rather than the real (and constantly + * rebuilt) site/. + */ +function makeSiteSandbox(files) { + const dir = mkdtempSync(join(tmpdir(), 'ailoud-check-docs-render-')); + made.push(dir); + cpSync(join(REPO, 'scripts'), join(dir, 'scripts'), { recursive: true }); + for (const [relativePath, content] of Object.entries(files)) { + const full = join(dir, 'site', relativePath); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content, 'utf8'); + } + return dir; +} + +const ARTICLE = (inner) => `
${inner}
`; + +describe('check-docs-render', () => { + it('exits clean and reports counts for a healthy site', () => { + const dir = makeSiteSandbox({ + 'index.html': ARTICLE('

Home

'), + 'mcp/index.html': ARTICLE( + '

Note

Fine.

', + ), + }); + + const result = run(dir, 'check-docs-render.mjs', [], { cwd: dir }); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/checked 2 page\(s\)/); + expect(result.stdout).toMatch(/clean/); + }); + + it('exits non-zero and names the file holding a broken admonition', () => { + const dir = makeSiteSandbox({ + 'mcp/index.html': ARTICLE( + '

Note

Fell out.

', + ), + }); + + const result = run(dir, 'check-docs-render.mjs', [], { cwd: dir }); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/\[admonition\]/); + expect(result.stderr).toMatch(/mcp[/\\]index\.html/); + }); + + it('refuses to run with no site/ built yet', () => { + const dir = mkdtempSync(join(tmpdir(), 'ailoud-check-docs-render-empty-')); + made.push(dir); + cpSync(join(REPO, 'scripts'), join(dir, 'scripts'), { recursive: true }); + + const result = run(dir, 'check-docs-render.mjs', [], { cwd: dir }); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/pnpm docs:build/); + }); +}); diff --git a/scripts/docs-surface.mjs b/scripts/docs-surface.mjs new file mode 100644 index 0000000..e589309 --- /dev/null +++ b/scripts/docs-surface.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// Print one sorted, deduplicated line per command, subcommand or flag +// documented anywhere under docs/ or in README.md. +// +// Usage: node scripts/docs-surface.mjs > surface.txt +// +// This is the safety net for compressing the documentation. Capture this +// output before a prose-cutting pass and after; comm -23 before.txt after.txt +// must print nothing. Anything it does print was documented and is not +// anymore -- only prose may be cut, never a command, subcommand, flag or +// option. See .superpowers/plans/2026-09-06-documentation-mcp-first.md. +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildSurface } from './lib/docsSurface.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +for (const line of buildSurface(ROOT)) console.log(line); diff --git a/scripts/docs-surface.test.mjs b/scripts/docs-surface.test.mjs new file mode 100644 index 0000000..3ad681e --- /dev/null +++ b/scripts/docs-surface.test.mjs @@ -0,0 +1,68 @@ +// This is the thin-wrapper test: it only checks that the CLI finds the right +// files and prints what the library builds. The extraction rules themselves +// are covered in scripts/lib/docsSurface.test.mjs, against the pure function +// directly, not by spawning a process per case. +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { REPO, run } from './testing/harness.mjs'; + +const made = []; +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A throwaway repository holding only the scripts and a fixture docs/ tree. + * + * docs-surface.mjs resolves docs/ and README.md relative to its own file + * location, not the working directory, so copying scripts/ next to a fixture + * tree is what puts it somewhere with content we control rather than the real + * (and constantly changing) documentation. + */ +function makeDocsSandbox(files) { + const dir = mkdtempSync(join(tmpdir(), 'ailoud-docs-surface-')); + made.push(dir); + cpSync(join(REPO, 'scripts'), join(dir, 'scripts'), { recursive: true }); + mkdirSync(join(dir, 'docs'), { recursive: true }); + for (const [relativePath, content] of Object.entries(files)) { + const full = join(dir, relativePath); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content, 'utf8'); + } + return dir; +} + +describe('docs-surface', () => { + it('reports the sorted, deduplicated surface across README.md and docs/', () => { + const dir = makeDocsSandbox({ + 'README.md': '```shell\nailoud audio ls --json\n```\n', + 'docs/usage/recordings.md': 'Run `ailoud audio ls` again for the same thing.\n', + 'docs/mcp.md': '`ailoud mcp` serves the library. See `ailoud mcp install`.\n', + }); + + const result = run(dir, 'docs-surface.mjs', [], { cwd: dir }); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + + const lines = result.stdout.trim().split('\n'); + expect(lines).toEqual([...lines].sort()); + expect(new Set(lines).size).toBe(lines.length); + expect(lines).toEqual( + expect.arrayContaining(['--json', 'ailoud audio ls', 'ailoud mcp', 'ailoud mcp install']), + ); + }); + + it('reads only README.md and docs/, not a sibling file like CONTRIBUTING.md', () => { + const dir = makeDocsSandbox({ + 'README.md': 'Nothing documented here.\n', + 'CONTRIBUTING.md': '`ailoud audio ls --secret`\n', + }); + + const result = run(dir, 'docs-surface.mjs', [], { cwd: dir }); + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); +}); diff --git a/scripts/fold-prereleases.mjs b/scripts/fold-prereleases.mjs new file mode 100755 index 0000000..2cce9a4 --- /dev/null +++ b/scripts/fold-prereleases.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// Fold the pre-release sections of a version back into one released section. +// +// Usage: node scripts/fold-prereleases.mjs +// +// Cutting 1.0.0 after 1.0.0-dev.1, -dev.2 and -rc.1 leaves four changelog +// sections describing one release: three pre-release ones plus whatever +// accumulated in `## Development`. A reader of the released notes wants one. +// This merges them into `## Version `, keeps the subsection grouping, +// drops duplicates, and removes the pre-release sections. +// +// Only the SAME version's pre-releases are folded: 1.0.0-dev.1 goes into 1.0.0 +// and never into 1.1.0. That is what stops an entry from an abandoned line +// reappearing under a release it was never part of. +import { + HARD_LIMIT, + SOFT_LIMIT, + escapeForRegExp, + fail, + fingerprint, + groupBullets, + readChanges, + splitSections, + versionFromTag, + versionHeading, + warn, + writeChanges, +} from './lib/changelog.mjs'; + +const SCOPE = 'fold-prereleases'; + +const version = versionFromTag(process.argv[2] ?? ''); +if (!/^\d+\.\d+\.\d+$/.test(version)) { + fail(SCOPE, `expected a released version like 1.0.0, got "${process.argv[2] ?? ''}"`); +} + +const { head, sections } = splitSections(readChanges()); + +const isOwnPrerelease = (heading) => + new RegExp(`^## Version ${escapeForRegExp(version)}-`).test(heading); +const isDevelopment = (heading) => /^## Development$/.test(heading); +const isTarget = (heading) => versionHeading(version).test(heading); +const isFolded = (heading) => + isOwnPrerelease(heading) || isDevelopment(heading) || isTarget(heading); + +const folded = sections.filter((section) => isFolded(section.heading)); +if (folded.length === 0) fail(SCOPE, 'nothing to fold: no Development and no matching sections'); +const prereleaseCount = folded.filter((section) => isOwnPrerelease(section.heading)).length; + +// Sections are read in document order, which puts Development first and the +// pre-releases below it, newest to oldest. First occurrence of a duplicate +// wins, so the newest wording of an entry is the one kept. +const merged = new Map(); +const seen = new Set(); +for (const section of folded) { + for (const [name, entries] of groupBullets(section.body)) { + for (const entry of entries) { + const key = fingerprint(entry); + if (key === '' || seen.has(key)) continue; + seen.add(key); + if (!merged.has(name)) merged.set(name, []); + merged.get(name).push(entry); + } + } +} + +const total = [...merged.values()].reduce((sum, entries) => sum + entries.length, 0); +if (total === 0) fail(SCOPE, 'every folded section was empty'); +if (total > HARD_LIMIT) { + fail( + SCOPE, + `the folded section would have ${total} entries; the hard limit is ${HARD_LIMIT}. ` + + 'Merge related entries in CHANGES.md before cutting the release.', + ); +} +if (total > SOFT_LIMIT) { + warn(`${SCOPE}: ${total} entries, over the soft limit of ${SOFT_LIMIT}.`); +} + +const body = [...merged.entries()] + .filter(([, entries]) => entries.length > 0) + .map(([name, entries]) => [`### ${name}`, '', ...entries.map((e) => e.join('\n'))].join('\n')) + .join('\n\n'); + +const kept = sections.filter((section) => !isFolded(section.heading)); + +writeChanges( + [ + head.replace(/\n+$/, ''), + '## Development', + `## Version ${version}`, + body, + ...kept.map((section) => [section.heading, section.body.replace(/\n+$/, '')].join('\n')), + ] + .filter((part) => part !== '') + .join('\n\n') + .replace(/\n{3,}/g, '\n\n'), +); + +console.log( + `${SCOPE}: folded ${prereleaseCount} pre-release section(s) and Development into ` + + `## Version ${version} (${total} entries)`, +); diff --git a/scripts/fold-prereleases.test.mjs b/scripts/fold-prereleases.test.mjs new file mode 100644 index 0000000..8e78ea0 --- /dev/null +++ b/scripts/fold-prereleases.test.mjs @@ -0,0 +1,114 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { changes, entries, makeSandbox, run, useSandboxes } from './testing/harness.mjs'; + +useSandboxes(); + +describe('fold-prereleases', () => { + const withPrereleases = changes( + [ + '## Development', + '', + '### Added', + '', + '- Newest, from Development.', + '', + '## Version 1.0.0-dev.2', + '', + '### Added', + '', + '- From dev.2.', + '- A duplicate that', + ' wraps across lines.', + '', + '### Fixed', + '', + '- Only dev.2 had this.', + '', + '## Version 1.0.0-dev.1', + '', + '### Added', + '', + '- From dev.1.', + '- A duplicate that wraps across lines.', + '', + '## Version 0.9.0', + '', + '### Added', + '', + '- An older release.', + '', + ].join('\n'), + ); + + it('merges the pre-releases and Development into one released section', () => { + const dir = makeSandbox(withPrereleases); + const result = run(dir, 'fold-prereleases.mjs', ['1.0.0']); + expect(result.code).toBe(0); + const out = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + expect(out).toContain('## Version 1.0.0'); + expect(out).not.toContain('1.0.0-dev.1'); + expect(out).not.toContain('1.0.0-dev.2'); + }); + + it('drops a duplicate even when one copy was rewrapped', () => { + const dir = makeSandbox(withPrereleases); + run(dir, 'fold-prereleases.mjs', ['1.0.0']); + const out = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + expect(out.match(/duplicate that/g)).toHaveLength(1); + }); + + it('keeps the subsection grouping', () => { + const dir = makeSandbox(withPrereleases); + run(dir, 'fold-prereleases.mjs', ['1.0.0']); + const out = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + expect(out).toContain('### Added'); + expect(out).toContain('### Fixed'); + expect(out).toContain('- Only dev.2 had this.'); + }); + + it('leaves an unrelated release alone', () => { + // 1.0.0-dev.1 folds into 1.0.0 and never into another version. + const dir = makeSandbox(withPrereleases); + run(dir, 'fold-prereleases.mjs', ['1.0.0']); + const out = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + expect(out).toContain('## Version 0.9.0'); + expect(out).toContain('- An older release.'); + }); + + it('leaves an empty Development heading for the next cycle', () => { + const dir = makeSandbox(withPrereleases); + run(dir, 'fold-prereleases.mjs', ['1.0.0']); + const out = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + const development = out.slice(out.indexOf('## Development'), out.indexOf('## Version 1.0.0')); + expect(development).not.toMatch(/^- /m); + }); + + it('is idempotent: folding twice changes nothing more', () => { + const dir = makeSandbox(withPrereleases); + run(dir, 'fold-prereleases.mjs', ['1.0.0']); + const once = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + run(dir, 'fold-prereleases.mjs', ['1.0.0']); + expect(readFileSync(join(dir, 'CHANGES.md'), 'utf8')).toBe(once); + }); + + it('refuses anything that is not a released version', () => { + const dir = makeSandbox(withPrereleases); + for (const bad of ['1.0.0-dev.1', 'latest', '1.0', '']) { + const result = run(dir, 'fold-prereleases.mjs', bad === '' ? [] : [bad]); + expect(result.code, bad).toBe(1); + expect(result.stderr, bad).toMatch(/expected a released version/); + } + }); + + it('refuses past the hard limit rather than writing an oversized section', () => { + const dir = makeSandbox(changes(`## Development\n\n${entries(51)}\n`)); + const before = readFileSync(join(dir, 'CHANGES.md'), 'utf8'); + const result = run(dir, 'fold-prereleases.mjs', ['1.0.0']); + expect(result.code).toBe(1); + expect(result.stderr).toMatch(/hard limit is 50/); + // And left the file exactly as it was. + expect(readFileSync(join(dir, 'CHANGES.md'), 'utf8')).toBe(before); + }); +}); diff --git a/scripts/lib/changelog.mjs b/scripts/lib/changelog.mjs new file mode 100644 index 0000000..879b9fa --- /dev/null +++ b/scripts/lib/changelog.mjs @@ -0,0 +1,152 @@ +// Shared vocabulary for the release scripts: the limits, the two ways of +// reporting, and the CHANGES.md parsing all three of them need. +// +// Extracted because the numbers and the parsing were copied into every script +// that touched the changelog. Copied limits drift, and a limit that differs +// between the script that warns and the script that refuses is worse than no +// limit -- one of them is then wrong and nobody knows which. +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * The published packages, in dependency order. + * + * The CLI is last because the other two are its dependencies: publish and + * deprecate both have to walk them in this order. + */ +export const PACKAGES = ['@ailoud/core', '@ailoud/providers', 'ailoud']; + +/** + * Which pre-release tags belong to a released version, and which may be deleted. + * + * Pure: it takes the tag list and an "is this commit on main" predicate rather + * than running git, so the decision is testable without a repository. + */ +export function planRetirement(version, tags, isOnMain) { + const prefix = `v${version}-`; + const mine = tags.filter((tag) => tag.startsWith(prefix)).sort(); + return { + versions: mine.map((tag) => versionFromTag(tag)), + deletable: mine.filter((tag) => isOnMain(tag)), + kept: mine.filter((tag) => !isOnMain(tag)), + }; +} + +/** Entries per version section. Stated once here, quoted in AGENTS.md and CHANGES.md. */ +export const SOFT_LIMIT = 10; +export const HARD_LIMIT = 50; + +/** Where the repository root is, relative to scripts/lib/. */ +export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const CHANGES_PATH = join(ROOT, 'CHANGES.md'); + +/** Stops with a message the caller owns. */ +export function fail(scope, message) { + console.error(`${scope}: ${message}`); + process.exit(1); +} + +/** + * A warning, not an error. + * + * `console.error` made a soft-limit notice look like a failure in a terminal + * and in a CI log alike. Under GitHub Actions this becomes an annotation, + * which is what a warning should be there. + */ +export function warn(message) { + if (process.env.GITHUB_ACTIONS === 'true') { + console.log(`::warning::${message}`); + return; + } + console.warn(message); +} + +/** `v1.2.3` and `1.2.3` both mean 1.2.3. */ +export function versionFromTag(tag) { + return String(tag).replace(/^v/, ''); +} + +/** The release part of a version: `1.0.0-dev.2` -> `1.0.0`. */ +export function baseVersion(version) { + return version.split('-')[0]; +} + +export function isPrerelease(version) { + return version.includes('-'); +} + +/** Safe inside a RegExp built from a version string. */ +export function escapeForRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function readChanges() { + return readFileSync(CHANGES_PATH, 'utf8'); +} + +export function writeChanges(text) { + writeFileSync(CHANGES_PATH, `${text.trimEnd()}\n`); +} + +/** Every `## ` section as {heading, body}, plus whatever precedes the first one. */ +export function splitSections(text) { + const lines = text.split('\n'); + const starts = []; + lines.forEach((line, at) => { + if (line.startsWith('## ')) starts.push(at); + }); + const head = lines.slice(0, starts[0] ?? lines.length).join('\n'); + const sections = starts.map((start, index) => ({ + heading: lines[start], + body: lines.slice(start + 1, starts[index + 1] ?? lines.length).join('\n'), + })); + return { head, sections }; +} + +/** + * Bullets of a section body, grouped by their `### ` subsection. + * + * A continuation line is attached to the bullet above it rather than dropped, + * which is what keeps an 80-column entry whole -- every wrapped entry would + * otherwise lose everything after its first line. + */ +export function groupBullets(body) { + const groups = new Map(); + let current = 'Added'; + for (const line of body.split('\n')) { + if (line.startsWith('### ')) { + current = line.slice(4).trim(); + if (!groups.has(current)) groups.set(current, []); + continue; + } + if (/^\s*- /.test(line)) { + if (!groups.has(current)) groups.set(current, []); + groups.get(current).push([line]); + continue; + } + const entries = groups.get(current); + if (entries !== undefined && entries.length > 0 && line.trim() !== '') { + entries[entries.length - 1].push(line); + } + } + return groups; +} + +export function countBullets(body) { + return body.split('\n').filter((line) => /^\s*- /.test(line)).length; +} + +/** Whitespace-insensitive, so the same entry rewrapped is still the same entry. */ +export function fingerprint(entry) { + return entry.join(' ').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +/** The body of the section whose heading matches, or null. */ +export function findSection(sections, pattern) { + return sections.find((section) => pattern.test(section.heading)) ?? null; +} + +export function versionHeading(version) { + return new RegExp(`^## Version ${escapeForRegExp(version)}(\\s|$)`); +} diff --git a/scripts/lib/changelog.test.mjs b/scripts/lib/changelog.test.mjs new file mode 100644 index 0000000..a65495b --- /dev/null +++ b/scripts/lib/changelog.test.mjs @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { + HARD_LIMIT, + SOFT_LIMIT, + baseVersion, + countBullets, + escapeForRegExp, + fingerprint, + groupBullets, + isPrerelease, + planRetirement, + splitSections, + versionFromTag, + versionHeading, +} from './changelog.mjs'; + +describe('the limits', () => { + it('are stated once, and the soft one is below the hard one', () => { + // Copied limits drift, and a limit that differs between the script that + // warns and the script that refuses is worse than no limit at all. + expect(SOFT_LIMIT).toBe(10); + expect(HARD_LIMIT).toBe(50); + expect(SOFT_LIMIT).toBeLessThan(HARD_LIMIT); + }); +}); + +describe('versionFromTag', () => { + it('strips one leading v and nothing else', () => { + expect(versionFromTag('v1.2.3')).toBe('1.2.3'); + expect(versionFromTag('1.2.3')).toBe('1.2.3'); + expect(versionFromTag('v1.2.3-dev.1')).toBe('1.2.3-dev.1'); + // Not a recursive strip: a version does not start with v twice. + expect(versionFromTag('vv1.2.3')).toBe('v1.2.3'); + }); +}); + +describe('baseVersion and isPrerelease', () => { + it('splits a pre-release from its release', () => { + expect(baseVersion('1.0.0-dev.2')).toBe('1.0.0'); + expect(baseVersion('1.0.0')).toBe('1.0.0'); + expect(isPrerelease('1.0.0-dev.2')).toBe(true); + expect(isPrerelease('1.0.0-rc.1')).toBe(true); + expect(isPrerelease('1.0.0')).toBe(false); + }); +}); + +describe('escapeForRegExp and versionHeading', () => { + it('does not let a dot in a version match any character', () => { + // Unescaped, `1.0.0` matches `1x0y0` -- and a heading for another version. + expect(escapeForRegExp('1.0.0')).toBe('1\\.0\\.0'); + expect(versionHeading('1.0.0').test('## Version 1.0.0')).toBe(true); + expect(versionHeading('1.0.0').test('## Version 1x0y0')).toBe(false); + }); + + it('matches a heading with a trailing date but not a longer version', () => { + expect(versionHeading('1.0.0').test('## Version 1.0.0 -- 2026-09-05')).toBe(true); + expect(versionHeading('1.0.0').test('## Version 1.0.0-dev.1')).toBe(false); + expect(versionHeading('1.0.0').test('## Version 1.0.10')).toBe(false); + }); +}); + +describe('splitSections', () => { + const text = [ + '# Title', + '', + 'preamble', + '', + '## Development', + '', + '- one', + '', + '## Version 1.0.0', + '', + '- two', + ].join('\n'); + + it('keeps whatever precedes the first section', () => { + expect(splitSections(text).head).toContain('preamble'); + }); + + it('returns each section with its heading and body', () => { + const { sections } = splitSections(text); + expect(sections.map((s) => s.heading)).toEqual(['## Development', '## Version 1.0.0']); + expect(sections[0].body).toContain('- one'); + expect(sections[1].body).toContain('- two'); + }); + + it('handles a file with no sections at all', () => { + const { head, sections } = splitSections('# Just a title\n'); + expect(sections).toEqual([]); + expect(head).toContain('Just a title'); + }); + + it('does not treat a ### subsection as a section', () => { + const { sections } = splitSections('## Version 1.0.0\n\n### Added\n\n- x\n'); + expect(sections).toHaveLength(1); + expect(sections[0].body).toContain('### Added'); + }); +}); + +describe('groupBullets', () => { + it('groups by subsection, in first-seen order', () => { + const groups = groupBullets('### Added\n\n- a\n\n### Fixed\n\n- b\n'); + expect([...groups.keys()]).toEqual(['Added', 'Fixed']); + expect(groups.get('Added')).toEqual([['- a']]); + }); + + it('keeps a wrapped entry whole', () => { + // Every 80-column entry wraps; dropping the continuation would lose + // everything after the first line. + const groups = groupBullets('### Added\n\n- first line\n second line\n'); + expect(groups.get('Added')).toEqual([['- first line', ' second line']]); + }); + + it('defaults to Added when a body has no subsection heading', () => { + expect([...groupBullets('- loose entry\n').keys()]).toEqual(['Added']); + }); + + it('ignores a blank line between entries', () => { + expect(groupBullets('- a\n\n- b\n').get('Added')).toEqual([['- a'], ['- b']]); + }); + + it('records a subsection that has no entries', () => { + const groups = groupBullets('### Added\n\n### Fixed\n\n- b\n'); + expect(groups.get('Added')).toEqual([]); + }); +}); + +describe('fingerprint', () => { + it('sees the same entry rewrapped as the same entry', () => { + // The reason a duplicate across two dev tags is caught even after one of + // them was reflowed. + expect(fingerprint(['- a duplicated entry that', ' wraps across two lines.'])).toBe( + fingerprint(['- a duplicated entry that wraps across two lines.']), + ); + }); + + it('is case-insensitive', () => { + expect(fingerprint(['- Same Thing'])).toBe(fingerprint(['- same thing'])); + }); + + it('keeps genuinely different entries apart', () => { + expect(fingerprint(['- one'])).not.toBe(fingerprint(['- two'])); + }); +}); + +describe('countBullets', () => { + it('counts entries, not their continuation lines', () => { + expect(countBullets('- a\n continued\n- b\n')).toBe(2); + }); + + it('counts an indented entry', () => { + expect(countBullets(' - nested\n')).toBe(1); + }); + + it('counts nothing in prose', () => { + expect(countBullets('### Added\n\nsome prose\n')).toBe(0); + }); +}); + +describe('planRetirement', () => { + const tags = [ + 'v1.0.0-dev.2', + 'v1.0.0-dev.1', + 'v1.0.0-rc.1', + 'v1.1.0-dev.1', + 'v1.0.0', + 'v10.0.0-dev.1', + ]; + + it('takes the pre-releases of one version and no others', () => { + // v1.1.0-dev.1 belongs to a version that has not been released, and + // v10.0.0-dev.1 shares a prefix with "v1" only as text. + const { versions } = planRetirement('1.0.0', tags, () => true); + expect(versions).toEqual(['1.0.0-dev.1', '1.0.0-dev.2', '1.0.0-rc.1']); + }); + + it('leaves the final release itself alone', () => { + const { versions } = planRetirement('1.0.0', tags, () => true); + expect(versions).not.toContain('1.0.0'); + }); + + it('splits the tags by whether their commit is on main', () => { + // A tag whose commit is not reachable from main cannot be deleted: the + // commit could then be collected, and the published provenance attests it. + const onMain = (tag) => tag !== 'v1.0.0-dev.2'; + const { deletable, kept } = planRetirement('1.0.0', tags, onMain); + expect(deletable).toEqual(['v1.0.0-dev.1', 'v1.0.0-rc.1']); + expect(kept).toEqual(['v1.0.0-dev.2']); + }); + + it('plans nothing for a version that never had a pre-release', () => { + const plan = planRetirement('2.0.0', tags, () => true); + expect(plan).toEqual({ versions: [], deletable: [], kept: [] }); + }); +}); diff --git a/scripts/lib/checkDocsRender.mjs b/scripts/lib/checkDocsRender.mjs new file mode 100644 index 0000000..784cc1b --- /dev/null +++ b/scripts/lib/checkDocsRender.mjs @@ -0,0 +1,293 @@ +// Pure logic behind scripts/check-docs-render.mjs. +// +// Kept out of the CLI file on purpose: this repository has twice had to move +// logic out of a `scripts/*.mjs` entry point into `scripts/lib/` because a +// test that imported the CLI file ran the CLI. Nothing here touches the +// filesystem except `collectSiteFiles` and `checkSite`, which only read. +// +// Why this exists: `mkdocs build --strict` checks the nav, internal links and +// references, but it has no idea what the page LOOKS like once Material has +// rendered it. A `!!! note` block whose content is not indented four spaces +// builds green and renders an empty box, with the note's own text falling out +// as an ordinary paragraph below it -- that shipped in this repository once. +// The strict build cannot see it because nothing about the markdown was +// invalid; it rendered exactly as written, just not as intended. The four +// checks here all share that shape: each looks for the literal, textual +// symptom left behind in the HTML when a piece of markdown parsed as +// something other than what its author meant, rather than for a markdown +// error the build would already have caught. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * The main content region of one rendered page -- between `
` + * and its matching `
` -- or the whole document if a page has no + * article wrapper (there is exactly one per page in this theme, but a + * missing wrapper should not crash the check, only widen it). + * + * Restricting every check to this region is what keeps the nav, the search + * modal markup and the generated table of contents from ever being mistaken + * for page content: none of those are what an author wrote. + */ +export function extractArticle(html) { + const open = /]*>/.exec(html); + if (!open) return html; + const start = open.index + open[0].length; + const end = html.indexOf('', start); + return end === -1 ? html.slice(start) : html.slice(start, end); +} + +/** + * `articleHtml` with every `
...
` block removed. + * + * The table, fence and heading checks all look for a piece of markdown + * syntax leaking into the page as literal text -- a real, correctly + * highlighted code sample is exactly the one place that kind of text is + * supposed to appear (a shell comment starting with `#`, a shown-not-run + * command piped with `|`), so it is cut before any of those checks run. + */ +function withoutCodeBlocks(articleHtml) { + return articleHtml.replace(/]*>[\s\S]*?<\/pre>/g, ''); +} + +/** + * `html` with every tag removed, leaving only the text a reader would see. + * + * Two things a one-pass `<[^>]+>` gets wrong, and both are why this looks + * heavier than it should: + * + * A quoted attribute may CONTAIN `>`. `
` stops the naive + * match early and leaves `">` behind as stray text -- which matters because + * the callers hunt for markdown syntax that leaked into the page as literal + * text, and a leftover fragment is exactly what they would report. A checker + * that invents findings gets ignored, which costs more than it saves. So the + * pattern below steps over quoted runs. + * + * And stripping once is not enough: on `<