diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec92e1eb..3a3307356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes - A broken team file no longer wipes or downgrades what a member has installed. An `mcp/mcp.yaml` or `hooks/hooks.yaml` that did not parse reconciled to an empty set and removed every team MCP server or hook from every tool, and two active namespaces defining one skill or agent aborted the pull for the whole scope, skipping rules, env, docs and cleanup. Now a file in the active set that does not parse or cannot be read, a name repeated inside one file, or one name in two active namespaces stops only that resource type for the run: env keeps `env.sh`, hooks and MCP keep their entries (the built-in hooks, with the session-start pull, are still installed where missing: with the root hooks file's `builtin:` overrides when it parses, and otherwise with their defaults only in a tool that has none yet; `teamai init` says the team hooks were not installed), model profiles leave switched agents alone, skills and agents keep what is installed, and every other type still syncs. The warning names the file or both files and the fix, and for env, hooks, MCP and models is also written to `~/.teamai/debug.log` for session-start pulls (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). +- In single-repo (`kind: self`) mode, every checkout of the business repo now publishes learnings and reports. The `teamai-learnings` and `teamai-reports` checkouts and the queue of unpublished learnings lived in each checkout's own `.teamai/`, and git checks a branch out in one worktree only, so the first checkout to create them locked every other one out (`'teamai-learnings' is already used by worktree`), and a learning queued in a linked worktree was deleted with it by a plain `git worktree remove`. They now live in the project partition that every checkout shares, and the search index is kept per checkout, so `recall` no longer serves another checkout's index with paths into it. On upgrade, `init`, `pull`, `push`, `contribute` and `import --from-mr` migrate a checkout's old install, moving its queue into the partition without overwriting anything (or into `pending-learnings.self` when another checkout has since switched the project to another kind; an old git-mode install beside the knowledge of a switch to single-repo mode goes to `.teamai.bak`, its queue to `pending-learnings.git`); `contribute` and `import --from-mr` stop, saving nothing, while that data cannot move out of the checkout; and the first command that needs a side-branch checkout removes the old one from `.teamai/`; one with uncommitted changes is kept, a warning names it and says what to do, and `recall maintenance` and `recall promote` stop until then, while another command holds the learnings or reports lock, and when a checkout cannot be created, naming the cause. A git-mode install of the same project uses the same partition paths, so a checkout that belongs to the other repository is refused, never reused or removed, with the command that clears it, and so is one whose repository was moved or deleted; its votes are not counted, `init` deletes the search indexes built for the old repository, learnings the old install still had queued are set aside in `pending-learnings.` rather than published by the new one (and in `pending-learnings.-` when `init` points an install at another team repository of the same kind, which on `main` published them to the new one; the same repository written another way keeps them, [#823](https://github.com/Tencent/teamai-cli/issues/823) item 13), and `uninstall` lists every unpublished queue before it asks. (for [#808](https://github.com/Tencent/teamai-cli/issues/808)). - A scope's usage file no longer grows without bound where it is never reported: an http source, a team with `usageReport: false`, or a remote that rejects every push. `teamai pull` keeps its newest 5,000 events, so `teamai stats` still shows recent usage there, the local file being its only source. In a reporting scope whose report does not complete while it holds more than 5,000 events, the dropped events were never reported. The cap runs after the report has removed the events it sent, so it cannot shift that removal onto events not yet reported, and a file at or below the cap is not rewritten. Hook appends, the report's truncate and the cap now share one lock beside the usage file, and a rewrite goes through a temp file that keeps the file's mode, so a rewrite no longer loses an event recorded while it runs, nor the file on a kill or a full disk. A hook that cannot take the lock within ~250 ms records its event in a `*.pending-.jsonl` file beside it, which the next lock holder appends; a rewrite that cannot take it within ~5 s leaves the file as it is. A pending file gets no wider mode than the usage file (owner-only while there is none), and an in-workspace `.teamai/.gitignore` ignores the lock, a rewrite's temp copy and the pending files; `pull` and `push` add those entries to an existing single-repo one, and the first pending file or rewrite adds them to an existing project-scope one (for [#788](https://github.com/Tencent/teamai-cli/issues/788)). - `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (only the sessions recorded in the current scope, see #785) and adds only what that scope has not reported yet, derived from the same per-scope `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — a snapshot can name a session the team file never received, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The data-partition migration no longer retires a legacy `/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 8243e28ea..87ff235cb 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -220,8 +220,15 @@ old installs through a legacy fallback; P1-3 moves a real legacy `.teamai/` INTO partition on the next write command, so the workspace ends up with zero residue. **Trigger** (`src/migrate.ts`, wired into the global `preAction` hook in `index.ts`): -- Only `init` / `pull` / `push`. Read-only commands (`status`, `recall`, …) keep using - the double-read fallback and never move data. +- Only `init` / `pull` / `push` / `contribute`, and `import --from-mr`: the + commands that write, and the two that queue learnings, which an unmigrated + checkout would keep in its own `.teamai/` (#808). `import`'s other modes leave + the queue alone, and `import --cache-status --json` prints only its JSON. + `contribute --scope user` (the user install's queue) and `import --from-mr + --output` (drafts only) do not write this project's queue, so they do not + migrate either. + Read-only commands (`status`, `recall`, …) keep using the double-read fallback + and never move data. - `hook-dispatch` is excluded outright (via `TEAMAI_HOOK_SUBCOMMANDS`): it is a high-frequency silent path and must never move 12 MB. - `--dry-run` (the existing global flag) previews without writing. @@ -230,17 +237,30 @@ partition on the next write command, so the workspace ends up with zero residue. short-circuits on an existing partition and runs the self-heal bootstrap as a side effect, both of which would mask the raw legacy state). Act iff: - in a git repo (the partition only exists for git repos), AND -- `/.teamai/config.yaml` exists, AND -- the legacy config is `scope: project` (user data never lives under `.teamai/`), AND -- the legacy config is NOT `kind: self` — **self mode is a hard no-op**: its `.teamai/` - is team knowledge committed to main, and `init --self` already retires any partition, - so moving it would break "knowledge on main". - -The plan's **mode** then depends on the partition: a full copy when +- `/.teamai/config.yaml` exists (self mode falls back to the + partition's, see below), AND +- the legacy config is `scope: project` (user data never lives under `.teamai/`). + +A `kind: self` config plans **mode `self`** whenever any entry the self migration +takes out (the machine data, the old queue, the old shared search index) is still +in the checkout's `.teamai/`. That `.teamai/` is team knowledge committed to main, +so it is never copied whole or renamed: the self migration moves only those +entries into the partition (see P2 below). Self mode reads its scope and kind +from the partition's `config.yaml` when the checkout's is gone, so a relocation +interrupted after the config moved still finishes. A partition `config.yaml` that +exists but that detection cannot read plans nothing, with a warning naming the +file, as for the other kinds below: the checkout's config is the only one that +still loads, and nothing leaves the checkout until the member fixes that file. + +For any other kind, the plan's **mode** then depends on the partition: a full copy when `/config.yaml` does not exist yet, or **retire-only** when it exists and detection can read it (a prior run built the partition but was interrupted before retiring the source — see Interrupt recovery). retire-only never re-copies onto the -authoritative partition; it only cleans up the leftover legacy dir. A partition +authoritative partition; it only cleans up the leftover legacy dir, after settling +its queue by the partition's config (settleCheckoutQueue, below): into the partition +queue, or set aside, never into `.teamai.bak`, which a removed linked worktree takes +with it. A queue that cannot move keeps the legacy dir (`'skipped'`), so +`contribute` and `import --from-mr` stop on it and the next run tries again. A partition `config.yaml` that exists but that detection cannot read (it is empty or cannot be opened, does not parse, does not validate, or is not `scope: project`) plans nothing: the legacy dir holds the only config that still loads, so it stays in place (a warning @@ -248,7 +268,23 @@ names the file) until the member fixes the partition file, and the next write co then gets the retire-only cleanup. A partition dir with no `config.yaml` at all (say, one moved aside by hand) plans nothing either, with a warning: the full copy replaces the whole dir, so it would take that dir's data with it. The full copy's re-check under -the lock in `runMigration` applies the same rules, warning included. +the lock in `runMigration` applies the same rules, warning and queue included. + +A readable self partition over a legacy dir that holds self knowledge (a +`teamai.yaml` with `mode: self`) plans **superseded** instead of retire-only: +another checkout ran `init --self`, and this one checked out what it committed +next to its old install, so retiring the whole dir would take the knowledge too +(#808). Its machine entries (`SUPERSEDED_ENTRIES`: state, token, the `env` file, +the team-repo clone, indexes, report and usage data) move to a new +`.teamai.bak[.N]` with its own `.gitignore` (`*`), as step 5 keeps a retired dir; +the knowledge stays. Then its queue is set aside as `pending-learnings.` +(settleCheckoutQueue, below), and only then does `config.yaml` follow: it is what +tells the next run the queue is the old install's. A learning still queued at that +point (a `contribute` from a teamai older than the queue lock, below, running +beside the migration) keeps it in place (`'skipped'`), as retire-only keeps its dir. A move that fails partway leaves it in +place too. Either way the next run plans superseded again and moves what is left +(into the next free `.teamai.bak.N`). With the legacy config +gone, the next run plans the self branch, which finds nothing left to move. **Steps** (`runMigration`) — copy → verify → atomic rename, so an interruption never leaves data half-in-both-places: @@ -314,12 +350,13 @@ installs: attach a partition `dataHome` to the self LocalConfig, and every **Invariant:** `getKnowledgeDir` / `repo.localPath` stay `/.teamai` — that is the class-B knowledge anchor, committed to main, and the ~230 `path.join(localPath, -…)` call sites do not change. `reports-wt/`, `learnings-wt/` and `knowledge-wt/` -stay in the repo too (git worktrees must live in the same repo; they anchor on -`localPath`, not `getDataHome`). Learnings themselves left the default branch in +…)` call sites do not change. Learnings themselves left the default branch in issue #485: new ones are written to `learnings-wt/` (the `teamai-learnings` branch) and queued in `pending-learnings/` until they are published, while the -learnings already on main are read from where they are. +learnings already on main are read from where they are. Where those two live is +in "Self mode and linked worktrees (#808)" below; only the disposable +`knowledge-wt/` (a detached checkout for knowledge PRs, removed after each use) +stays in the checkout's `.teamai/`. - **init** (`initSelfRepo`): resolves the partition up front, attaches it as `dataHome`, and writes config/state there. The pre-P2 "retire the stale @@ -337,15 +374,228 @@ learnings already on main are read from where they are. - **migration** (`migrate.ts`, `mode: 'self'`): self CANNOT use the git-mode whole directory copy→rename (that would carry the knowledge off and rename `.teamai` to `.bak`, breaking "knowledge on main"). Instead it selectively relocates the A1 - whitelist (config.yaml, state.json, env.local, env.sh, search-index.json, - managed-mcp.json, workspaces/) entry-by-entry, destination-first (copy to the + whitelist (config.yaml, state.json, env.local, env.sh, managed-mcp.json, + workspaces/) entry-by-entry, destination-first (copy to the partition, then delete the source), leaving class-B knowledge and the worktrees untouched and never renaming `.teamai/`. self `repo.localPath` is NOT rebased — - it must keep pointing at the in-repo knowledge. + it must keep pointing at the in-repo knowledge. It also drains the checkout's + queue (`pending-learnings/`, see #808 below), and deletes the old shared + `search-index.json` instead of moving it: self mode keeps one index per + checkout under `workspaces//` and rebuilds it. Acceptance: after slimming, `git status` is clean (the A1 data is physically gone, not merely ignored) and a teammate's fresh clone bootstraps into the partition. +### Self mode and linked worktrees (#808) + +Git checks a branch out in one worktree only. Self mode used to derive the +`teamai-learnings` and `teamai-reports` checkouts, and the queue, from +`localPath`, which is re-anchored to each checkout's `.teamai/`. So the first +checkout to create a side-branch checkout owned the branch, every other checkout +of the repo failed with `'teamai-learnings' is already used by worktree`, and a +learning queued in a linked worktree was deleted with it (its `.teamai/` is +ignored, so a plain `git worktree remove` takes it). The partition is shared by +every checkout, so that is where they live now: + +```text +~/.teamai/projects// one per project, every checkout +├── learnings-wt/ getWorktreeDir → / +├── reports-wt/ (the side-branch locks sit beside them) +├── pending-learnings/ pendingLearningsDir → /pending-learnings +└── workspaces// + └── search-index.json getProjectSearchIndexPath, one per checkout +/.teamai/ one per checkout: committed knowledge, knowledge-wt/ +``` + +`git worktree add` takes a path outside the repo, and the owning repo is still +the business repo, whose refs every checkout shares. The search index is keyed +per checkout, like managed MCP, because each checkout indexes its own branch's +docs, rules and skills; a shared index served whichever checkout rebuilt it last, +with paths into that checkout. The learnings they index are shared, though, so +when `contribute` or `pull` rebuilds one checkout's index it deletes the other +checkouts' `workspaces/*/search-index.json` in the partition, and each rebuilds +from its own roots on its next `recall`. Git and http mode keep their paths: +their checkouts and queue already sat beside the shared clone. + +Upgrading from the per-checkout layout: + +- **Queue.** The self migration (`init` / `pull` / `push` / `contribute` / + `import --from-mr`, above) moves each + file of `/.teamai/pending-learnings/` into the partition queue, + atomically, never overwriting: a file with the same content there is the one + already moved, and one with different content stays in place with a warning. + The partition's config decides, not the checkout's: after `init` switched the + project to another kind from another checkout, a checkout that has not + migrated still says `kind: self`, and its queue would publish to the new + repository. It is set aside instead, as a mode switch does (below), to + `pending-learnings.self` beside the partition queue, with a warning naming + it; `contribute` and `import --from-mr` take the same step before they queue. + A queue with no `config.yaml` beside it is one an older self install left + after its config had moved to the partition; once the project serves another + install no plan covers it, so every migrating command sets it aside the same + way, as the self install's. Beside a legacy `config.yaml` that cannot be read + it stays, with a warning naming that file. + A superseded git install's queue goes to `pending-learnings.git` the same way, + and a git or http checkout's queue takes the same step before retire-only + retires the rest. A queue is the partition's only when both installs have the + same kind and team repository (#823 item 13, below): a checkout's git install + of another team repository has its queue set aside as + `pending-learnings.git-`. While the partition config cannot be read, the self + migration keeps everything in the checkout, queue included, with a warning. + Likewise, whenever a learning is still in the checkout's queue once it was + settled (the queue lock was busy, or one was queued meanwhile), the self + migration relocates nothing and reports `skipped`, keeping `config.yaml`, as + retire-only and superseded do: the next run settles it. + It runs per checkout; the old directory is the "not done yet" marker. + After the migration, `contribute` and `import --from-mr` stop with exit code 1 + and save nothing whenever a learning queued now would still be kept in the + checkout's `.teamai/` (`queueKeptInCheckout`): the migration stood down on the + checkout's busy `.teamai/.sync-lock`, the data home is still that directory + because the partition has no config detection can read, or an old queue there + could not move. The message names the cause and the next step. `init` + (except `--scope user`) stops the same way, before it writes anything: it + would set the project up and leave that queue for `git worktree remove` to + delete. A linked worktree that only ever contributes or imports does not + take a learning with it when removed. +- **Queue lock (#823 item 11).** A command loads its config long before it + queues, and in between the migration can move that config (it retires the + checkout's `.teamai/`, or relocates `config.yaml`) or `init` can switch the + project's kind or team repository. Queue writes (`savePendingLearning`, which `contribute` and + `import --from-mr` both use), the migration and the kind switch therefore + share one lock per queue home, the directory holding `pending-learnings/`: + `~/.teamai/locks/queue-.lock`. It + lives outside the home because the migration renames a checkout's + `.teamai/`, and a writer waiting on a lock inside it would create the + directory again; the sync lock is not reused because git pulls hold it for + their whole run. A write holds the lock only around the file write: under + it, it re-reads `/config.yaml` and saves nothing when that file is + gone, cannot be read or names another kind or team repository (`changed`). + The migration holds + the checkout's lock from after its sync lock to the end, through the rename + or the last `config.yaml` move; `settleCheckoutQueue` holds the partition's + around reading its install and moving the queue; `init` holds it around setting + the queue aside and saving the new config. The publish lists the queue under + the same lock and check, so a command whose install changed publishes + nothing: the learnings stay for the install they were written for. Waits + are bounded (30 x 100 ms): a write then exits 1 with `Another teamai command + is moving this project's queued learnings (...). Nothing was saved.`, the + migration returns `busy`, and `init` exits 1 without saving the new config. Locks are + taken in the order sync lock, checkout queue lock, partition queue lock, and + a queue write holds no other lock. A teamai older than this takes no queue + lock, so its `contribute` beside a migration can still leave a learning in + `.teamai.bak`. +- **Old checkouts.** Registrations are shared by every checkout, and one left in + any checkout's `.teamai/` blocks the shared one from all of them, so the + migration cannot wait for a pull in the right checkout. `ensureWorktree` + handles it on its cold path, just before `git worktree add`: for every + registration of the same branch at a path ending in `/.teamai/`, it + runs `git worktree remove` without `--force`. A clean one goes (its commits + are on the branch, which the new checkout reuses), so a `contribute` in a + linked worktree works with no pull first. One with uncommitted changes stays: + the side-branch step fails with an error naming the path and the next step + (commit or move the changes, or delete the path by hand), and the queue keeps + the learnings until then. The error is also printed as a warning, because + several callers treat a side-branch failure as non-fatal and log it at debug + only; a silent (hook) run prints nothing. `refresh` does not swallow it: + `recall maintenance` and `recall promote` stop with exit code 1 and write + nothing, since the shared checkout they would write into does not exist and + the next publish would clear what they wrote there as a stale directory. Any + other failure to create or sync the checkout (`git worktree add` refusing a + branch checked out at a path teamai does not know, say) makes `refresh` + return `failed` with the cause: readers use what is there, and maintenance + and promote stop the same way, naming the cause. +- **Lock.** Every checkout of the repo now shares each side-branch checkout + and its lock. A `refresh` that cannot take the lock (held, or not creatable) + checks nothing and never creates, prunes or removes a checkout, which may be + the holder's work in progress; it returns `busy` with the lock path, and a + reader (`members`, `projects members`, `digest`, `pull`, `stats` and `viz`, + through `readableReportsWorktree`) uses the local copy and never ensures it, + after the same ownership probe as `indexableVotesDir` (below): another + repository's copy is refused with `ForeignCheckoutError`. The + checkout there may be another repository's, or not created yet, so + `recall maintenance` and `recall promote` stop with exit code 1 and write + nothing when either the reports lock (they rank by its votes) or the + learnings lock is taken; the error names the lock and says to run the command + again, or to check that the partition is writable. + +A git-mode install keeps the same checkouts and queue at the same partition +paths (beside `/team-repo`), so after a project switches mode the +checkout there may belong to the other repository. `ensureWorktree` accepts an +existing checkout only when its `git rev-parse --git-common-dir`, resolved, +matches the owning repo's. Otherwise it fails with an error naming the checkout, +the repository it belongs to and the command that removes it +(`git -C worktree remove `); it never removes it itself. A +checkout git cannot open (`rev-parse` fails) is recreated only while the owning +repo still registers it: its `.git` file names a gitdir whose `commondir` leads +to the owning repo's git dir. A gitdir without it proves nothing: git pruned the +registration, and a clone at the same path (the clone was deleted and cloned +again, as `init` does when it switches to another team repository) is not the +repository the checkout came from. That checkout, and any other path with a +`.git` (one whose repository was moved or deleted, or a `.git` directory), is refused with +the same error type, saying teamai cannot show whose it is, and is never +removed; a path with no `.git` (a partial leftover) is cleared as before. The +refusal is a `ForeignCheckoutError`, which `refresh` does not swallow either, +because every path under the checkout is the other install's: +`recall maintenance` and `recall promote` stop with exit code 1 instead of +rewriting that team's learnings, and `members` and `projects members` stop the +same way on another repository's reports checkout. Every search-index build +(`pull`, `contribute`, and `recall` when it has no index) builds from +`indexableLearningsRoots`, which probes ownership and leaves out only that +checkout's learnings: the queue, docs, rules, skills and older learnings stay +recallable, and a plain `recall` runs no git. `digest` and the dashboard (`viz`: +its throwaway index and its promotion and prune candidates) use the same roots. +Those builds, and the reports checkout `teamai recall feedback --negative` counts +the team's upvotes from, take the votes directory from `indexableVotesDir`, +which runs the same probe on the reports checkout: another repository's votes +are left out, so no learning of this project carries that team's hotness. +The recall hook's vote judge, which starts no git process, reads the same answer +from git's files: the checkout's `.git` file names its gitdir, whose `commondir` +leads to the owning repository's git dir, compared (realpath'd) with this +project's. When they differ, or either cannot be read (a gitdir without +`commondir` included), the checkout's roots +leave the judge's allowed read roots; a path with no `.git` is judged as before. + +The queue is the same partition directory in both modes, and its learnings were +written for the previous install's repository. A queue's owner is its install's +kind and team repository (`repo.remote`, compared as `remotesMatch` compares +remotes: credentials, protocol, scp or URL form, a trailing `.git` or `/` and +case do not count; #823 item 13). When `init` changes the owner with learnings +still queued, it moves the queue, under the queue lock and together with saving +the new config (above), to `pending-learnings.`, or +`pending-learnings.-` when only the team repository changed (`` +is the compared form of the previous remote with `/` and the like as `-`, e.g. +`pending-learnings.git-github.com-org-team-a`), beside it (the first free name, +nothing is overwritten) and says how many learnings it set aside and where; +nothing is published to the new repository or deleted. A previous config that +exists but cannot be read names no owner: the loaders return null for it as for +a fresh install, so `init` checks for the file, and with learnings queued moves +them to `pending-learnings.unknown` the same way, the warning naming that config; +queued or not, it drops the search indexes, as on an owner change. +A self install whose +business repository moved to another URL (renamed or transferred) is another +owner too: `init` sets its queue aside, and the warning names the directory to +move the files back from. It also deletes every search index +in the data home (the root one and each `workspaces/*/`): they were built from +the other repository, and `recall` rebuilds a missing index. `uninstall` lists, before it asks, +how many unpublished learnings each queue in the data home holds, set-aside ones +included, so the member can publish or copy them first. + +Every checkout keeps its `workspaces//` (search index, managed MCP, +resource cache) in the shared data home. A full `pull` removes those of +checkouts `git worktree list` no longer shows; the fast path does not list +worktrees. + +`import --from-mr` queues its learning in `pendingLearningsDir` and publishes +it as `contribute` does (#823), so in self mode it lands in the partition queue +and needs no checkout before the extraction. Its supersede check reads the +queue and `indexableLearningsRoots`, never another repository's checkout. + +The self migration that moves queued learnings into the partition drops every +checkout's `workspaces/*/search-index.json`: none of them has the moved +learnings, and `recall` rebuilds a missing index. It drops them whenever a +learning ends up in the partition queue, including one an interrupted run had +already moved there, so a retry after a crash does not leave them stale. + ## P3 — constant functionization + `status --all` (implemented) **Functionization.** A handful of top-level path constants were computed once at diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 2f8f26510..94477309d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -163,14 +163,22 @@ Claude Code creates `.claude/`, then pull writes into it. A bare `teamai pull` s skips tools whose project root does not exist, so it never invents agent directories for tools you have not opened in this project. -> **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` after -> upgrading automatically migrates an existing `/.teamai/` into the partition +> **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` / +> `contribute` (or `import --from-mr`) after upgrading automatically migrates an existing `/.teamai/` into the partition > (copy → verify → atomic switch), then leaves the old directory as `/.teamai.bak/` -> for you to delete once you've confirmed everything works. If the partition already +> for you to delete once you've confirmed everything works. When another checkout of the +> repo already migrated, the old directory's queue of unpublished learnings moves into the +> partition's first, never into the backup. If the partition already > exists but its `config.yaml` cannot be read, or is missing, the migration keeps > `/.teamai/` and warns with the path: fix or restore that file (or move the -> config-less partition aside), and the next `init` / `pull` / `push` finishes the job. -> Read-only commands and the +> config-less partition aside), and the next of those commands finishes the job. +> Until the old directory's data has moved, `contribute`, `import --from-mr` and +> `init` (except `--scope user`) stop with exit code 1 and save nothing, naming the +> cause: another teamai command holding its lock, a partition `config.yaml` as above, +> or an old queue that could not move. +> Deal with that, then run them again. `contribute --scope user` and +> `import --from-mr --output` do not write this project's queue, so they neither +> migrate nor stop. Read-only commands and the > `hook-dispatch` path never migrate; `teamai --dry-run pull` previews the move. > **Downgrading afterwards is not supported** — an older teamai would treat the project > as uninitialized; `.teamai.bak/` is the manual rollback path. @@ -388,8 +396,57 @@ teamai init . --agent claude,codex # non-interactive: set up Claude Code + Cod | Knowledge: `skills/` `rules/` `docs/` `env/` `agents/`, `teamai.yaml` | `.teamai/` on the **main** branch | `teamai push` → pull request | No: push a branch, open a pull request | | `learnings/` | `teamai-learnings` **orphan branch** | `teamai contribute` → direct push | No | | Reports: `members/` `sessions/` `votes/` `stats/` | `teamai-reports` **orphan branch** | `init`, `session save`, hooks, pull auto-report | No | -| Machine-local: `config.yaml`, `state.json`, search index, env backup, MCP manifests | `~/.teamai/projects//` (**partition**, outside the repo) | local only | — | -| Disposable git worktrees (`reports-wt/`, `learnings-wt/`, `knowledge-wt/`) and the contribution queue (`pending-learnings/`) | `.teamai/` (gitignored; rebuilt on demand) | local only | — | +| Machine-local: `config.yaml`, `state.json`, search index (one per checkout), env backup, MCP manifests, the `reports-wt/` and `learnings-wt/` checkouts, the contribution queue (`pending-learnings/`) | `~/.teamai/projects//` (**partition**, outside the repo, shared by every worktree) | local only | — | +| Disposable knowledge-PR worktree (`knowledge-wt/`) | `.teamai/` (gitignored; rebuilt on demand) | local only | — | + +Git checks a branch out in one worktree only, so every checkout of the repo +shares the `teamai-learnings` and `teamai-reports` checkouts and the queue. An +older teamai kept them in each checkout's `.teamai/`. `init`, `pull`, `push`, +`contribute` and `import --from-mr` move that checkout's queue into the partition, and the first command that needs +a side-branch checkout removes the old one. An old checkout with uncommitted +changes is kept, and the command names it: nothing is published to or recalled +from that branch until you commit, move or delete those changes, and +`recall maintenance` and `recall promote` stop. Queued learnings stay queued +and recallable. Maintenance and promote also stop, naming the cause, when the +checkout cannot be created, such as when `teamai-learnings` is checked out +somewhere else. +A git-mode install of the same project keeps its checkouts at the same paths. +After switching modes, teamai refuses a checkout that belongs to the other +repository and prints the `git worktree remove` command that clears it: nothing +is published to it, indexed from it (its votes included) or rewritten in it +(`recall maintenance` and `recall promote` stop). Learnings still queued by the old install are moved +to `pending-learnings.` in the same data home, never published by the +new one; `init` says how many and where, and deletes the search indexes built +for the old repository (the next `recall` rebuilds them). Re-running `init` +against another team repository of the same kind does the same, to +`pending-learnings.-` (for example +`pending-learnings.git-github.com-org-team-a`); the same repository written +another way (with or without `.git`, SSH or HTTPS) keeps the queue. When the old +install's `config.yaml` exists but cannot be read, nothing says whose the queue is: +`init` moves it to `pending-learnings.unknown`, names that file and deletes the search indexes. A checkout that had +not been upgraded yet keeps its old queue the same way: the next command there +moves it to `pending-learnings.self` and names the path. The other way round, when +`init --self` in one checkout switches a git-mode project and another checkout that +still has its old install takes the knowledge from main, the next `init`, `pull`, +`push`, `contribute` or `import --from-mr` there moves that install's queue to +`pending-learnings.git` and the rest of it (config, clone, env and the like) to +`/.teamai.bak/`, leaving the knowledge in place. `teamai uninstall` lists every queue +with learnings not published yet before it asks to confirm. +A `contribute` or `import --from-mr` that queues its learning while a migration is +moving this checkout's data, or while `init` switches the project's mode or team repository, waits +for it (up to 3 seconds). If the install it started with has changed by then, it +saves nothing and exits 1 (`This project's teamai install changed while this +command ran`); run it again. If the other command is still at it after the wait, +it exits 1 the same way (`Another teamai command is moving this project's queued +learnings`). A learning queued just before the switch is set aside with the old +install's queue and never published to the new repository. This needs this +version on both sides: an older teamai's `contribute` running beside a migration +can still leave its learning in `.teamai.bak/`. +A checkout teamai cannot show to be this repository's is refused the same way and +never removed: one whose `.git` leads to a repository that was moved or deleted, +or one this repository no longer registers (its clone was deleted and cloned +again, as `init` does when it switches to another team repository at the same +path). Move it aside, or delete it if it holds nothing you need. Learnings a team wrote before they moved to their own branch stay on the default branch, exactly where they are. Nothing is copied, deleted or migrated: that @@ -420,7 +477,7 @@ branches at all. Machine-local data lives in the per-project **partition** outside the repo, so a single-repo `.teamai/` holds only the team knowledge committed to main — `git status` stays clean. Upgrading an older single-repo install relocates that machine -data into the partition automatically on the next `init`/`pull`/`push` (the +data into the partition automatically on the next `init`/`pull`/`push`/`contribute` (the knowledge on main is left exactly in place). **Clone = initialized.** Because knowledge and the `mode: self` marker in `.teamai/teamai.yaml` are committed to main, a teammate who clones the repo is auto-initialized: the next `teamai` command or AI session detects the marker, and (when their git provider is already authenticated) writes their local config, injects hooks, and registers them on the reports branch — no need to re-type repo/role. If they aren't authenticated yet, teamai prompts them to run `teamai init .` once. @@ -1174,6 +1231,8 @@ teamai recall maintenance --update-quality After `--update-quality`, review the generated `.draft.md` files and rename them to `.md` to apply the updates. +While another teamai command holds the learnings or reports checkout's lock, `recall maintenance` and `recall promote` exit 1 without writing anything (`The learnings checkout is locked: …`). Run them again when that command finishes. + ### Promoting Learnings When a learning reaches maturity, promote it to formal team knowledge (a skill, rule, or doc). Promotion criteria: confidence ≥ 0.90, ≥ 5 upvotes, ≥ 2 distinct contributors, age ≥ 14 days. @@ -1500,7 +1559,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich If core graph extraction or writing fails, the import reports an error without marking the commit as synced. The next incremental run retries that commit. -`--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it. +`--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. AI-backed steps (`--deep-enrich`, knowledge enrichment) shell out to an AI coding CLI already installed on the machine instead of calling a model API directly. teamai probes `claude` → `claude-internal` → `codex` → `codex-internal` → `codebuddy` → `workbuddy` → `openclaw` and uses the first one it finds. On macOS and Linux the probe runs through a login shell, so a CLI installed under `~/.nvm/` is found too. On Windows it uses the native `where`, which returns the npm shim (`%APPDATA%\npm\claude.cmd`) that Windows can actually launch — a Git Bash or WSL `bash` only reports MSYS paths such as `/c/Users/...`, which Windows cannot start. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 6cf4f68ac..846d761a6 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -157,11 +157,17 @@ teamai init https://github.com/yourorg/yourrepo 工具创建。例如,打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull` 仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目打开过的 Agent 凭空建目录。 -> **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` 会自动把已有的 +> **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` +> (或 `import --from-mr`)会自动把已有的 > `/.teamai/` 迁移进分区(复制 → 校验 → 原子切换),并把旧目录保留为 -> `/.teamai.bak/`,待你确认一切正常后自行删除。若分区已存在但其 `config.yaml` +> `/.teamai.bak/`,待你确认一切正常后自行删除。若仓库的另一个检出已完成迁移, +> 旧目录中尚未发布的 learning 队列会先移入分区的队列,绝不会进入备份。若分区已存在但其 `config.yaml` > 无法读取或缺失,迁移会保留 `/.teamai/` 并给出带路径的警告:修复或恢复该文件 -> (或把缺少 config 的分区移开)后,下一次 `init` / `pull` / `push` 会完成迁移。 +> (或把缺少 config 的分区移开)后,下一次执行上述任一命令会完成迁移。 +> 在旧目录的数据迁移完成之前,`contribute`、`import --from-mr` 与 `init`(`--scope user` 除外) +> 会以退出码 1 停止、不保存任何内容,并说明原因:另一个 teamai 命令正持有其锁、分区 `config.yaml` 如上所述不可用, +> 或旧队列无法移动。处理之后重新执行即可。`contribute --scope user` 与 +> `import --from-mr --output` 不写本项目的队列,因此既不触发迁移,也不会因此停止。 > 只读命令与 `hook-dispatch` 路径永不触发迁移;`teamai --dry-run pull` 可预演。**迁移后不支持降级**——旧版会把项目判定为 > 未初始化;`.teamai.bak/` 是人工回滚路径。 @@ -356,8 +362,38 @@ teamai init . --agent claude,codex # 非交互:启用 Claude Code + Codex | 知识资产:`skills/` `rules/` `docs/` `env/` `agents/`、`teamai.yaml` | **main** 分支的 `.teamai/` | `teamai push` → PR | 不需要:推送分支并开 PR 即可 | | `learnings/` | `teamai-learnings` **孤儿分支** | `teamai contribute` 直接推送 | 不需要 | | 上报数据:`members/` `sessions/` `votes/` `stats/` | `teamai-reports` **孤儿分支** | `init`、`session save`、hook、pull 自动上报 | 不需要 | -| 本机私有:`config.yaml`、`state.json`、搜索索引、env 备份、MCP manifest | `~/.teamai/projects//`(**分区**,在仓库之外) | 仅本地 | — | -| 可丢弃的 git worktree(`reports-wt/`、`learnings-wt/`、`knowledge-wt/`)与待发布队列(`pending-learnings/`) | `.teamai/`(已 gitignore;按需重建) | 仅本地 | — | +| 本机私有:`config.yaml`、`state.json`、搜索索引(每个检出一份)、env 备份、MCP manifest、`reports-wt/` 与 `learnings-wt/` 检出、待发布队列(`pending-learnings/`) | `~/.teamai/projects//`(**分区**,在仓库之外,所有 worktree 共用) | 仅本地 | — | +| 可丢弃的知识 PR worktree(`knowledge-wt/`) | `.teamai/`(已 gitignore;按需重建) | 仅本地 | — | + +git 同一分支只能在一个 worktree 中检出,所以仓库的所有检出共用 `teamai-learnings` +和 `teamai-reports` 的检出以及待发布队列。旧版 teamai 把它们放在每个检出自己的 +`.teamai/` 里。`init`、`pull`、`push`、`contribute` 和 `import --from-mr` 会把该检出的队列移入分区,第一个需要分支检出 +的命令会移除旧检出。旧检出中若有未提交的改动则保留,命令会指出其路径:在你提交、 +移走或删除这些改动之前,不会向该分支发布内容,也不会从该分支召回内容,`recall maintenance` 与 +`recall promote` 会停止。已排队的 learning +仍留在队列中,仍可被召回。检出无法创建时(例如 `teamai-learnings` 已在别处检出),maintenance 与 promote +同样会停止,并说明原因。 +同一项目的 git 模式安装把检出放在相同的路径。切换模式后,teamai 会拒绝使用属于另一个 +仓库的检出,并打印清除它的 `git worktree remove` 命令:不会向它发布、不会为它建索引(包括其中的投票)、 +也不会改写其中内容(`recall maintenance` 与 `recall promote` 会停止)。旧安装仍在队列中的 +learning 会被移到同一数据目录下的 `pending-learnings.<旧类型>`,新安装不会发布它们;`init` +会说明数量和位置,并删除为旧仓库构建的搜索索引(下一次 `recall` 会重建)。以同一类型对另一个团队仓库重新运行 `init` +也同样处理,队列移到 `pending-learnings.<类型>-<仓库>`(例如 `pending-learnings.git-github.com-org-team-a`); +同一仓库换一种写法(带或不带 `.git`、SSH 或 HTTPS)不会移动队列。若旧安装的 `config.yaml` 存在但无法读取, +就无从得知队列属于谁:`init` 会把它移到 `pending-learnings.unknown`,指出该文件,并删除搜索索引。尚未升级的检出中的旧队列也同样处理: +在该检出运行的下一个命令会把它移到 `pending-learnings.self` 并给出路径。反过来,当某个检出的 `init --self` +把 git 模式项目切换为单仓库模式,而另一个仍保留旧安装的检出从 main 取得了知识时,在那里运行的下一个 +`init`、`pull`、`push`、`contribute` 或 `import --from-mr` 会把旧安装的队列移到 `pending-learnings.git`, +其余部分(config、克隆、env 等)移到 `/.teamai.bak/`,知识保持不动。`teamai uninstall` 在请求确认前会列出每个仍有未发布 learning 的队列。 +若 `contribute` 或 `import --from-mr` 在迁移正在移动本检出数据、或 `init` 正在切换项目模式或团队仓库时写入队列, +它会等待对方完成(最多 3 秒)。若此时它启动时读取的安装已经变化,它不保存任何内容并以退出码 1 结束 +(`This project's teamai install changed while this command ran`),重新执行即可。若等待结束后对方仍未完成, +它同样以退出码 1 结束(`Another teamai command is moving this project's queued learnings`)。 +切换前刚写入的 learning 会随旧安装的队列一起被移开,绝不会发布到新仓库。这需要双方都是本版本: +旧版 teamai 的 `contribute` 若与迁移同时运行,其 learning 仍可能留在 `.teamai.bak/` 中。 +teamai 无法证明属于本仓库的检出同样会被拒绝,且永不删除:例如其 `.git` 指向已被移动或删除的仓库, +或本仓库已不再登记它(克隆被删除后重新 clone,`init` 在同一路径切换到另一个团队仓库时即是如此)。 +请把它移开,或在确认其中没有需要的内容后删除。 learnings 迁到独立分支之前团队已经写下的内容,原地留在默认分支上。不复制、不删除、 不迁移:该目录仍会被读取,所有既有 learning 依然能从 `teamai recall` 中找回。新的 @@ -383,7 +419,7 @@ learning 写入 `teamai-learnings`。 本机私有数据存放在仓库之外的按项目**分区**里,因此单仓模式的 `.teamai/` 只保留提交到 main 的团队知识 —— `git status` 保持干净。旧版单仓装升级后,下一次 -`init`/`pull`/`push` 会自动把这些机器数据搬进分区(main 上的知识原封不动)。 +`init`/`pull`/`push`/`contribute` 会自动把这些机器数据搬进分区(main 上的知识原封不动)。 **克隆即初始化。** 由于知识资产和 `.teamai/teamai.yaml` 里的 `mode: self` 标记都提交在 main 上,团队成员 clone 仓库后会被自动初始化:下一条 `teamai` 命令或 AI 会话会识别该标记,并(在其 git provider 已认证的前提下)自动写入本机配置、注入 hooks、在孤儿分支上注册成员 —— 无需手抄 repo/role 参数。若尚未认证,teamai 会提示其运行一次 `teamai init .`。 @@ -1106,6 +1142,8 @@ teamai recall maintenance --update-quality 运行 `--update-quality` 后,审查生成的 `.draft.md` 文件,将满意的文件重命名为 `.md` 即可应用更新。 +另一个 teamai 命令持有 learnings 或 reports checkout 的锁时,`recall maintenance` 与 `recall promote` 会以退出码 1 停止,不写入任何内容(`The learnings checkout is locked: …`)。待该命令结束后再运行。 + ### 晋升 Learnings 当 learning 达到成熟标准时,可将其晋升为正式团队知识(skill、rule 或 doc)。晋升判据:置信度 ≥ 0.90、≥ 5 次 upvote、≥ 2 个不同贡献者、存在时长 ≥ 14 天。 @@ -1419,7 +1457,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich 如果核心知识图谱提取或写入失败,导入会报错,且不会将该提交标记为已同步。下次增量导入会重试该提交。 -`--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它。 +`--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 需要 AI 的步骤(`--deep-enrich`、知识增强)复用本机已安装的 AI 编码 CLI,而不是直接调用模型 API。teamai 按 `claude` → `claude-internal` → `codex` → `codex-internal` → `codebuddy` → `workbuddy` → `openclaw` 的顺序探测,取第一个可用者。macOS / Linux 上探测经由 login shell,因此装在 `~/.nvm/` 下的 CLI 也能找到;Windows 上改用原生命令 `where`,拿到的是 Windows 真正能启动的 npm shim(`%APPDATA%\npm\claude.cmd`)——Git Bash 或 WSL 的 `bash` 只会返回 `/c/Users/...` 这类 MSYS 路径,Windows 无法启动。 diff --git a/src/__tests__/checkout-refusal-silent.test.ts b/src/__tests__/checkout-refusal-silent.test.ts new file mode 100644 index 000000000..68b3549c4 --- /dev/null +++ b/src/__tests__/checkout-refusal-silent.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { _setLogFilePath, setSilent } from '../utils/logger.js'; +import { savePendingLearning } from '../utils/pending-learnings.js'; +import { publishQueuedLearnings } from '../utils/learnings-publish.js'; +import { writeInstallConfig } from './helpers/install-config.js'; + +// Real-git test (#808): `import --from-mr` publishes with the logger silenced +// (its task list owns the terminal), so a refusal warned there is never shown. +// Its reason must then carry the whole message, not "see the warning above". + +const GIT_ENV = { + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.co', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.co', +}; + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } }); +} + +describe('a checkout refusal during a silent publish (#808)', () => { + let testRoot: string; + const originalHome = process.env.HOME; + + beforeEach(() => { + testRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-refusal-silent-'))); + process.env.HOME = testRoot; + _setLogFilePath(path.join(testRoot, 'debug.log')); + }); + + afterEach(() => { + setSilent(false); + process.env.HOME = originalHome; + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it('names the checkout and the way out when no warning was shown, and stays short once one was', async () => { + const businessRoot = path.join(testRoot, 'business'); + const remote = path.join(testRoot, 'remote.git'); + const dataHome = path.join(testRoot, 'partition'); + fs.mkdirSync(path.join(businessRoot, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(businessRoot, '.teamai', 'teamai.yaml'), 'mode: self\n'); + git(['init', '-q', '--bare', remote], testRoot); + git(['init', '-q', '-b', 'main'], businessRoot); + git(['config', 'user.email', 't@t.co'], businessRoot); + git(['config', 'user.name', 't'], businessRoot); + git(['add', '-A'], businessRoot); + git(['commit', '-q', '-m', 'init'], businessRoot); + git(['remote', 'add', 'origin', remote], businessRoot); + git(['push', '-q', '-u', 'origin', 'main'], businessRoot); + + // Another team's checkout of teamai-learnings where this project's would be. + const teamRemote = path.join(testRoot, 'team.git'); + const teamSeed = path.join(testRoot, 'team-seed'); + git(['init', '-q', '--bare', teamRemote], testRoot); + git(['init', '-q', '-b', 'teamai-learnings', teamSeed], testRoot); + fs.writeFileSync(path.join(teamSeed, 'README.md'), 'other team\n'); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'learnings'], teamSeed); + git(['remote', 'add', 'origin', teamRemote], teamSeed); + git(['push', '-q', 'origin', 'teamai-learnings'], teamSeed); + const teamClone = path.join(dataHome, 'team-repo'); + git(['clone', '-q', teamRemote, teamClone], testRoot); + git(['worktree', 'add', '-q', path.join(dataHome, 'learnings-wt'), 'teamai-learnings'], teamClone); + + const config = { + repo: { localPath: path.join(businessRoot, '.teamai'), kind: 'self' as const, businessRepoRoot: businessRoot, remote }, + username: 'test', + additionalRoles: [], + scope: 'project' as const, + projectRoot: businessRoot, + dataHome, + }; + writeInstallConfig(config); + const queued = await savePendingLearning(config, 'note-2026-01-01-aaaaaa.md', '# Note\n'); + expect(queued.status).toBe('saved'); + + setSilent(true); + const silent = await publishQueuedLearnings(config, 'test'); + setSilent(false); + expect(silent.published).toEqual([]); + expect(silent.lastError).toContain(`worktree remove ${path.join(dataHome, 'learnings-wt')}`); + expect(silent.lastError).not.toContain('see the warning above'); + + // Printed this time, so the reason a caller quotes points at it. + const shown = await publishQueuedLearnings(config, 'test'); + expect(shown.lastError).toBe('the teamai-learnings checkout belongs to another repository; see the warning above'); + }); +}); diff --git a/src/__tests__/contribute-edge-modes.test.ts b/src/__tests__/contribute-edge-modes.test.ts index 84a66e193..1af2ddf95 100644 --- a/src/__tests__/contribute-edge-modes.test.ts +++ b/src/__tests__/contribute-edge-modes.test.ts @@ -56,6 +56,7 @@ vi.mock('../config.js', async (importOriginal) => ({ const { contribute } = await import('../contribute.js'); const { listPendingLearnings } = await import('../utils/pending-learnings.js'); +const { writeInstallConfig } = await import('./helpers/install-config.js'); function note(text: string): string { const p = path.join(tmp, `note-${Math.random().toString(36).slice(2)}.md`); @@ -84,6 +85,7 @@ describe('contributing where the learnings branch cannot exist', () => { const business = path.join(tmp, 'product'); fs.mkdirSync(path.join(business, '.teamai'), { recursive: true }); execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: business }); + writeInstallConfig(config()); await expect( contribute({ scope: 'project', title: 'no commits yet', file: note('# knowledge') }), diff --git a/src/__tests__/contribute-self-learnings.test.ts b/src/__tests__/contribute-self-learnings.test.ts index 9a2f47cbe..9ada34529 100644 --- a/src/__tests__/contribute-self-learnings.test.ts +++ b/src/__tests__/contribute-self-learnings.test.ts @@ -22,6 +22,9 @@ const localConfig = { updatePolicy: 'auto' as const, additionalRoles: [], scope: 'project' as const, + // Detection always sets it; the data home, which holds the queue and the + // side-branch checkouts in self mode, is derived from it (#808). + projectRoot: businessRoot, }; vi.mock('../config.js', async (importOriginal) => ({ @@ -47,6 +50,7 @@ vi.mock('../utils/logger.js', () => ({ const { contribute } = await import('../contribute.js'); const { buildSelfModeGitignore } = await import('../init.js'); const { getUserLearningsDir } = await import('../types.js'); +const { writeInstallConfig } = await import('./helpers/install-config.js'); function git(args: string[], cwd: string) { execFileSync('git', args, { cwd, stdio: 'ignore' }); @@ -72,6 +76,8 @@ describe('contributeSelf — machine-local learnings cache (issue #472)', () => git(['commit', '-qm', 'init'], businessRoot); git(['remote', 'add', 'origin', remote], businessRoot); git(['push', '-u', 'origin', 'main'], businessRoot); + // Ignored, as a real install's is. + writeInstallConfig(localConfig); // Pre-existing cache content this contribution must never touch: another // project's shared root learning, plus a namespace directory unrelated to diff --git a/src/__tests__/contribute-unpublishable.test.ts b/src/__tests__/contribute-unpublishable.test.ts index 9041433a3..43b14116e 100644 --- a/src/__tests__/contribute-unpublishable.test.ts +++ b/src/__tests__/contribute-unpublishable.test.ts @@ -40,6 +40,7 @@ vi.mock('../config.js', async (importOriginal) => ({ })); const { contribute } = await import('../contribute.js'); +const { writeInstallConfig } = await import('./helpers/install-config.js'); /** * Whatever goes wrong with git, a contribution the member has already made has @@ -53,6 +54,7 @@ describe('contributing when the learnings branch cannot be written at all', () = process.env.HOME = path.join(tmp, 'home'); fs.mkdirSync(path.join(tmp, 'not-a-clone'), { recursive: true }); fs.mkdirSync(process.env.HOME, { recursive: true }); + writeInstallConfig(config()); }); afterEach(() => { diff --git a/src/__tests__/e2e/import-mr-publish-823.test.ts b/src/__tests__/e2e/import-mr-publish-823.test.ts index 3f3c0a729..2a6a249ff 100644 --- a/src/__tests__/e2e/import-mr-publish-823.test.ts +++ b/src/__tests__/e2e/import-mr-publish-823.test.ts @@ -251,6 +251,7 @@ describe('import --from-mr publishes its learning (#823)', () => { it('keeps the learning queued and says the next pull publishes it', () => { expect(imported.code, imported.output).toBe(0); expect(imported.output).toContain('Learning saved locally'); + expect(imported.output).toContain('the next `teamai pull` publishes it'); expect(state.publishedAfterImport).toEqual([]); expect(state.queuedAfterImport.filter((f) => /^retry-flaky-s3-uploads-.*\.md$/.test(f)), imported.output).toHaveLength(1); }); @@ -261,6 +262,30 @@ describe('import --from-mr publishes its learning (#823)', () => { }); }); + describe('when a learnings checkout teamai refuses is in the way', () => { + const s = scenario(); + let imported: RunResult = { code: null, output: '' }; + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + // A checkout whose repository is gone: every pull refuses it too. + const checkout = path.join(s.home, '.teamai', 'learnings-wt'); + fs.mkdirSync(path.join(checkout, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(checkout, '.git'), `gitdir: ${path.join(s.home, '..', 'gone', '.git', 'worktrees', 'learnings-wt')}\n`); + imported = await s.run(importMr); + }, 120_000); + afterAll(() => s.cleanup()); + + it('keeps the learning queued and says no pull publishes it until that checkout is dealt with', () => { + expect(imported.code, imported.output).toBe(0); + expect(imported.output).toContain('Learning saved locally'); + expect(imported.output).toContain('no `teamai pull` can publish it until that checkout is dealt with: do what the refusal says'); + expect(imported.output).not.toContain('the next `teamai pull` publishes it'); + expect(s.queued().filter((f) => /^retry-flaky-s3-uploads-.*\.md$/.test(f)), imported.output).toHaveLength(1); + expect(s.published()).toEqual([]); + }); + }); + describe('on a read-only HTTP source', () => { const s = scenario(); let imported: RunResult = { code: null, output: '' }; diff --git a/src/__tests__/e2e/self-mode-worktrees-808.test.ts b/src/__tests__/e2e/self-mode-worktrees-808.test.ts new file mode 100644 index 000000000..8ef3f4fe1 --- /dev/null +++ b/src/__tests__/e2e/self-mode-worktrees-808.test.ts @@ -0,0 +1,1478 @@ +/** + * E2E (#808): in self mode, every checkout of the business repo publishes + * learnings and keeps its queue, whichever checkout ran first. + * + * Git lets a branch be checked out in one worktree only. The teamai-learnings + * and teamai-reports checkouts, and the queue of unpublished learnings, used to + * live in each checkout's own `.teamai/`, so the first checkout to create them + * locked every other one out, and a learning queued in a linked worktree was + * deleted with it. The search index was shared by every checkout, so recall + * served whichever one rebuilt it last. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { projectSlug } from '../../utils/partition.js'; +import { queueLockPath } from '../../utils/pending-learnings.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], cwd: string, home: string): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + cwd, + env: { ...process.env, ...GIT_ENV, HOME: home, FORCE_COLOR: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('close', (code) => resolve({ code, output })); + }); +} + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } }); +} + +interface Project { + sandbox: string; + home: string; + remote: string; + projectRoot: string; + /** A linked worktree outside the main checkout, like `git worktree add ../b`. */ + worktree: string; +} + +const sandboxes: string[] = []; + +/** + * A self-mode business repo with a bare origin. Team knowledge is committed + * under .teamai/, and the machine config sits in the checkout, as a + * pre-partition install left it: the first pull moves it into the partition. + */ +function setUpProject(): Project { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-issue808-e2e-'))); + sandboxes.push(sandbox); + const project: Project = { + sandbox, + home: path.join(sandbox, 'home'), + remote: path.join(sandbox, 'origin.git'), + projectRoot: path.join(sandbox, 'project'), + worktree: path.join(sandbox, 'project-wt'), + }; + const { home, remote, projectRoot } = project; + fs.mkdirSync(home, { recursive: true }); + + git(['init', '-q', '--bare', remote], sandbox); + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote); + + const knowledge = path.join(projectRoot, '.teamai'); + fs.mkdirSync(path.join(knowledge, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(knowledge, 'learnings', '.gitkeep'), ''); + fs.writeFileSync(path.join(knowledge, 'teamai.yaml'), [ + 'team: issue-808-e2e', + 'mode: self', + 'repo: https://git.example.com/team/project.git', + 'provider: git', + '', + ].join('\n')); + fs.writeFileSync(path.join(knowledge, '.gitignore'), 'config.yaml\nstate.json\n'); + git(['init', '-q', '-b', 'main'], projectRoot); + git(['add', '-A'], projectRoot); + git(['commit', '-q', '-m', 'project'], projectRoot); + git(['remote', 'add', 'origin', remote], projectRoot); + git(['push', '-q', '-u', 'origin', 'main'], projectRoot); + + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${remote}`, + ' kind: self', + ` businessRepoRoot: ${projectRoot}`, + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${projectRoot}`, + '', + ].join('\n')); + return project; +} + +/** Learnings on origin's teamai-learnings branch. */ +function published(project: Project): string[] { + return git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], project.remote) + .split('\n') + .filter((f) => f.startsWith('learnings/')); +} + +/** The `File:` paths a recall printed. */ +function recalledFiles(output: string): string[] { + return [...output.matchAll(/^File: (.+)$/gm)].map((m) => m[1].trim()); +} + +/** Reject every push to origin while `fn` runs, so a contribution stays queued. */ +async function withPushesRejected(project: Project, fn: () => Promise): Promise { + const hook = path.join(project.remote, 'hooks', 'pre-receive'); + fs.writeFileSync(hook, '#!/bin/sh\necho "rejected by the #808 test" >&2\nexit 1\n', { mode: 0o755 }); + try { + return await fn(); + } finally { + fs.rmSync(hook, { force: true }); + } +} + +function note(project: Project, name: string, text: string): string { + const file = path.join(project.sandbox, `${name}.md`); + fs.writeFileSync(file, `${text}\n`); + return file; +} + +/** The project partition: exactly one exists under the sandbox HOME. */ +function partitionOf(project: Project): string { + const projects = path.join(project.home, '.teamai', 'projects'); + const [partition] = fs.readdirSync(projects); + return path.join(projects, partition); +} + +/** The partition's queue. */ +function partitionQueue(project: Project): string[] { + const queue = path.join(partitionOf(project), 'pending-learnings'); + return fs.existsSync(queue) ? fs.readdirSync(queue, { recursive: true }).map(String) : []; +} + +/** The queue lock of `home` under the sandbox HOME, as the CLI resolves it. */ +async function sandboxQueueLock(project: Project, home: string): Promise { + const realHome = process.env.HOME; + process.env.HOME = project.home; + try { + return await queueLockPath(home); + } finally { + process.env.HOME = realHome; + } +} + +/** Hold `lock` as a live teamai process would: this test's own process. */ +function holdLock(lock: string): void { + fs.mkdirSync(path.dirname(lock), { recursive: true }); + fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'issue-808-e2e' })); +} + +/** + * An install an older teamai left in a linked worktree: its config and one + * queued learning in its `.teamai/`. `remote` is the origin URL its init saw. + */ +function writeUnmigratedWorktree(project: Project, queued: string, remote = project.remote): string { + const knowledge = path.join(project.worktree, '.teamai'); + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${remote}`, + ' kind: self', + ` businessRepoRoot: ${project.worktree}`, + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${project.worktree}`, + '', + ].join('\n')); + fs.mkdirSync(path.join(knowledge, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(knowledge, 'pending-learnings', queued), '# Queued by the older teamai\n'); + return knowledge; +} + +/** A checkout's directory name under `/workspaces/`, as teamai keys it (managedMcpWorkspaceId). */ +function workspaceId(checkout: string): string { + return createHash('sha1').update(checkout).digest('hex').slice(0, 12); +} + +describe('self mode with a linked worktree (#808)', () => { + beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + }); + + afterEach(() => { + for (const sandbox of sandboxes.splice(0)) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('publishes from both checkouts, keeps a worktree queue after the worktree is removed, and recalls only live paths', async () => { + const project = setUpProject(); + const { home, projectRoot, worktree } = project; + + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + // The pull brings .teamai/.gitignore up to date; commit it as it asks, so + // the worktree starts from a clean tree. + git(['add', '.teamai/.gitignore'], projectRoot); + git(['commit', '-q', '-m', 'gitignore'], projectRoot); + + // The worktree's branch carries a doc the main checkout does not have. + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + fs.mkdirSync(path.join(worktree, '.teamai', 'docs'), { recursive: true }); + fs.writeFileSync(path.join(worktree, '.teamai', 'docs', 'wt-only.md'), '# Wombat note from the worktree branch\n'); + git(['add', '-A'], worktree); + git(['commit', '-q', '-m', 'worktree doc'], worktree); + + // The main checkout created its learnings checkout during the pull, so the + // worktree is the one that used to be locked out. + const fromWorktree = await runCLI( + ['contribute', '--title', 'wt-note', '--file', note(project, 'wt', 'Wombat note contributed from the worktree')], + worktree, + home, + ); + expect(fromWorktree.code, fromWorktree.output).toBe(0); + expect(fromWorktree.output).not.toContain('already used by worktree'); + expect(fromWorktree.output).not.toContain('Saved locally'); + + const fromMain = await runCLI( + ['contribute', '--title', 'main-note', '--file', note(project, 'main', 'Wombat note contributed from the main checkout')], + projectRoot, + home, + ); + expect(fromMain.code, fromMain.output).toBe(0); + expect(fromMain.output).not.toContain('Saved locally'); + + expect(published(project).some((f) => f.includes('wt-note-')), published(project).join('\n')).toBe(true); + expect(published(project).some((f) => f.includes('main-note-')), published(project).join('\n')).toBe(true); + + // The issue's order: the main checkout contributed, then the worktree. The + // main checkout's recall lists both, with paths that exist. + const later = await runCLI( + ['contribute', '--title', 'later-note', '--file', note(project, 'later', 'Wombat note contributed later from the worktree')], + worktree, + home, + ); + expect(later.code, later.output).toBe(0); + const recallAfterWorktree = await runCLI(['recall', 'wombat', 'note'], projectRoot, home); + const afterWorktree = recalledFiles(recallAfterWorktree.output); + for (const title of ['main-note-', 'later-note-']) { + expect(afterWorktree.some((f) => path.basename(f).startsWith(title)), recallAfterWorktree.output).toBe(true); + } + for (const file of afterWorktree) { + expect(fs.existsSync(file), `${file} does not exist`).toBe(true); + } + + // A contribution the remote refuses stays queued. + const queued = await withPushesRejected(project, () => runCLI( + ['contribute', '--title', 'queued-note', '--file', note(project, 'queued', 'Wombat note queued in the worktree')], + worktree, + home, + )); + expect(queued.output).toContain('Saved locally'); + + // A plain remove, no --force: ignored files do not make a worktree dirty. + git(['worktree', 'remove', worktree], projectRoot); + expect(fs.existsSync(worktree)).toBe(false); + + // The main checkout's recall still serves its own checkout: every path it + // prints exists, and none points into the removed worktree. + const recallMain = await runCLI(['recall', 'wombat', 'note'], projectRoot, home); + expect(recallMain.code, recallMain.output).toBe(0); + const files = recalledFiles(recallMain.output); + expect(files.length, recallMain.output).toBeGreaterThan(0); + for (const file of files) { + expect(file.startsWith(worktree), `${file} points into the removed worktree`).toBe(false); + expect(fs.existsSync(file), `${file} does not exist`).toBe(true); + } + + // The next pull in the main checkout publishes what the worktree queued. + // As a full sync it also drops the removed worktree's per-checkout + // directory, and keeps the main checkout's. + const workspaces = path.join(partitionOf(project), 'workspaces'); + const worktreeDir = path.join(workspaces, workspaceId(worktree)); + const mainDir = path.join(workspaces, workspaceId(projectRoot)); + expect(fs.existsSync(worktreeDir)).toBe(true); + const pullMain = await runCLI(['pull'], projectRoot, home); + expect(pullMain.code, pullMain.output).toBe(0); + expect(published(project).some((f) => f.includes('queued-note-')), published(project).join('\n')).toBe(true); + expect(fs.existsSync(worktreeDir)).toBe(false); + expect(fs.existsSync(mainDir)).toBe(true); + + const recallAll = await runCLI(['recall', 'wombat', 'note'], projectRoot, home); + expect(recallAll.code, recallAll.output).toBe(0); + const recalled = recalledFiles(recallAll.output); + for (const title of ['wt-note-', 'main-note-', 'queued-note-']) { + expect(recalled.some((f) => path.basename(f).startsWith(title)), recallAll.output).toBe(true); + } + for (const file of recalled) { + expect(fs.existsSync(file), `${file} does not exist`).toBe(true); + } + }); + + it('moves a queue out of a linked worktree an older teamai never migrated before contribute publishes, so removing the worktree loses nothing', async () => { + const project = setUpProject(); + const { home, projectRoot, worktree } = project; + + // The worktree's own pre-partition install: its config and queue in its .teamai/. + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + const knowledge = path.join(worktree, '.teamai'); + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${project.remote}`, + ' kind: self', + ` businessRepoRoot: ${worktree}`, + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${worktree}`, + '', + ].join('\n')); + const oldQueued = 'old-queued-2026-01-01-dddddd.md'; + fs.mkdirSync(path.join(knowledge, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(knowledge, 'pending-learnings', oldQueued), '# Queued by the older teamai\n'); + + // Import modes that leave the queue alone do not migrate: their JSON stays clean. + const cacheStatus = await runCLI(['import', '--cache-status', '--json'], worktree, home); + expect(cacheStatus.output).not.toContain('Migrated'); + expect(fs.existsSync(path.join(knowledge, 'config.yaml'))).toBe(true); + + const queued = await withPushesRejected(project, () => runCLI( + ['contribute', '--title', 'unmigrated-note', '--file', note(project, 'unmigrated', 'Note queued in an unmigrated worktree')], + worktree, + home, + )); + expect(queued.output).toContain('Saved locally'); + + // --force deletes everything in the worktree, so what survives was kept elsewhere. + git(['worktree', 'remove', '--force', worktree], projectRoot); + expect(fs.existsSync(worktree)).toBe(false); + // An unmigrated contribute never creates the partition. + const queue = fs.existsSync(path.join(home, '.teamai', 'projects')) ? partitionQueue(project) : []; + expect(queue, queue.join('\n')).toContain(oldQueued); + expect(queue.some((f) => f.startsWith('unmigrated-note-')), queue.join('\n')).toBe(true); + + const pull = await runCLI(['pull'], projectRoot, home); + expect(pull.code, pull.output).toBe(0); + expect(published(project), published(project).join('\n')).toContain(`learnings/${oldQueued}`); + expect(published(project).some((f) => f.includes('unmigrated-note-')), published(project).join('\n')).toBe(true); + }); + + it('stops contribute in an unmigrated linked worktree while another command holds its sync lock, saving nothing there', async () => { + const project = setUpProject(); + const { home, projectRoot, worktree } = project; + + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + const knowledge = path.join(worktree, '.teamai'); + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${project.remote}`, + ' kind: self', + ` businessRepoRoot: ${worktree}`, + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${worktree}`, + '', + ].join('\n')); + // A live holder: this test's own process, as an unmigrated pull would be. + fs.writeFileSync( + path.join(knowledge, '.sync-lock'), + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'issue-808-e2e' }), + ); + + const run = await runCLI( + ['contribute', '--title', 'locked-note', '--file', note(project, 'locked', 'Note contributed while the sync lock is held')], + worktree, + home, + ); + expect(run.code, run.output).toBe(1); + expect(run.output).toContain( + "teamai could not move this checkout's data into the project's shared data directory (another teamai command is using it). " + + 'Nothing was saved. Run this again when that command finishes.', + ); + expect(fs.existsSync(path.join(knowledge, 'pending-learnings'))).toBe(false); + expect(fs.existsSync(path.join(knowledge, 'config.yaml'))).toBe(true); + const queue = fs.existsSync(path.join(home, '.teamai', 'projects')) ? partitionQueue(project) : []; + expect(queue, queue.join('\n')).toEqual([]); + }); + + it('stops contribute with exit 1 while another command holds the queue lock, saving nothing (#823 item 11)', async () => { + const project = setUpProject(); + const { home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const lock = await sandboxQueueLock(project, partitionOf(project)); + holdLock(lock); + + const run = await runCLI( + ['contribute', '--title', 'held-note', '--file', note(project, 'held', 'Note contributed while the queue lock is held')], + projectRoot, + home, + ); + // Nothing was published at all: the learnings branch does not exist yet. + const branch = git(['for-each-ref', '--format=%(refname)', 'refs/heads/teamai-learnings'], project.remote); + const onOrigin = branch ? published(project) : []; + expect(onOrigin.some((f) => f.includes('held-note-')), run.output).toBe(false); + expect(partitionQueue(project)).toEqual([]); + expect(run.output).toContain( + `Another teamai command is moving this project's queued learnings (${lock} is held). ` + + 'Nothing was saved. Run this again when it finishes.', + ); + expect(run.code, run.output).toBe(1); + }); + + it("stops init in an unmigrated linked worktree while its queue would stay there, then moves it, so removing the worktree loses nothing (#808)", async () => { + const project = setUpProject(); + const { home, projectRoot, worktree } = project; + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + const queued = 'old-queued-2026-01-01-eeeeee.md'; + // init needs no network here: an unreachable origin fails the GitLab probe + // at once, and the member registration push is best effort. + const origin = 'http://127.0.0.1:9/team/project.git'; + git(['config', 'user.name', 'TeamAI CI'], projectRoot); + git(['config', 'user.email', 'ci@teamai.test'], projectRoot); + git(['remote', 'set-url', 'origin', origin], projectRoot); + // The older install was set up from the same origin (another one would own + // another queue, #823 item 13). + const knowledge = writeUnmigratedWorktree(project, queued, origin); + + // Another command holds the worktree's sync lock, so the migration cannot run. + const syncLock = path.join(knowledge, '.sync-lock'); + holdLock(syncLock); + const busy = await runCLI(['init', '--self', '--force'], worktree, home); + expect(busy.output).toContain( + "teamai could not move this checkout's data into the project's shared data directory (another teamai command is using it). " + + 'Nothing was saved. Run this again when that command finishes.', + ); + expect(busy.code, busy.output).toBe(1); + fs.rmSync(syncLock); + + // The partition's config cannot be read, so the migration stands down. + const partition = path.join(home, '.teamai', 'projects', projectSlug(projectRoot)); + fs.mkdirSync(partition, { recursive: true }); + fs.writeFileSync(path.join(partition, 'config.yaml'), 'repo: "unterminated\n'); + const unreadable = await runCLI(['init', '--self', '--force'], worktree, home); + expect(unreadable.output).toContain( + `teamai could not move this checkout's data into the project's shared data directory (${path.join(partition, 'config.yaml')} cannot be read`, + ); + expect(unreadable.code, unreadable.output).toBe(1); + expect(fs.readFileSync(path.join(partition, 'config.yaml'), 'utf8')).toBe('repo: "unterminated\n'); + expect(fs.readdirSync(path.join(knowledge, 'pending-learnings'))).toEqual([queued]); + fs.rmSync(path.join(partition, 'config.yaml')); + + const init = await runCLI(['init', '--self', '--force'], worktree, home); + expect(init.code, init.output).toBe(0); + git(['worktree', 'remove', '--force', worktree], projectRoot); + expect(partitionQueue(project)).toContain(queued); + }); + + it("stops contribute when an unmigrated worktree's old queue cannot move, saving nothing", async () => { + const project = setUpProject(); + const { home, projectRoot, worktree } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + const knowledge = path.join(worktree, '.teamai'); + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${project.remote}`, + ' kind: self', + ` businessRepoRoot: ${worktree}`, + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${worktree}`, + '', + ].join('\n')); + // The same name in both queues with other content: only a hand edit gets + // there, and teamai keeps both for the member to compare. + const clash = 'clash-note-2026-01-01-cccccc.md'; + fs.mkdirSync(path.join(knowledge, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(knowledge, 'pending-learnings', clash), '# The worktree copy\n'); + const partitionClash = path.join(partitionOf(project), 'pending-learnings', clash); + fs.mkdirSync(path.dirname(partitionClash), { recursive: true }); + fs.writeFileSync(partitionClash, '# The partition copy\n'); + + const run = await runCLI( + ['contribute', '--title', 'blocked-note', '--file', note(project, 'blocked', 'Note contributed beside a stuck queue')], + worktree, + home, + ); + expect(run.code, run.output).toBe(1); + expect(run.output).toContain( + `teamai could not move this checkout's data into the project's shared data directory (the learnings queued in ${path.join(knowledge, 'pending-learnings')} could not be moved; see the warning above). Nothing was saved.`, + ); + expect(fs.readdirSync(path.join(knowledge, 'pending-learnings'))).toEqual([clash]); + expect(partitionQueue(project)).toEqual([clash]); + expect(fs.readFileSync(partitionClash, 'utf8')).toBe('# The partition copy\n'); + }); + + it('upgrades a checkout an older teamai left: keeps a dirty old checkout, then frees a clean one and drains every queue', async () => { + const project = setUpProject(); + const { home, remote, projectRoot, worktree } = project; + + // What an older teamai left: teamai-learnings checked out in the main + // checkout's .teamai/, and a queue in each checkout's .teamai/. + const seed = path.join(project.sandbox, 'seed'); + git(['clone', '-q', remote, seed], project.sandbox); + git(['checkout', '-q', '--orphan', 'teamai-learnings'], seed); + git(['rm', '-rfq', '.'], seed); + fs.mkdirSync(path.join(seed, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(seed, '.gitignore'), 'reports-wt/\nlearnings-wt/\nknowledge-wt/\n'); + fs.writeFileSync(path.join(seed, 'learnings', '.gitkeep'), ''); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'learnings branch'], seed); + git(['push', '-q', 'origin', 'teamai-learnings'], seed); + git(['fetch', '-q', 'origin', 'teamai-learnings:teamai-learnings'], projectRoot); + const oldCheckout = path.join(projectRoot, '.teamai', 'learnings-wt'); + git(['worktree', 'add', '-q', oldCheckout, 'teamai-learnings'], projectRoot); + // Uncommitted work in the old checkout: git will not remove it without --force. + const stray = path.join(oldCheckout, 'draft.txt'); + fs.writeFileSync(stray, 'unsaved\n'); + + // With nothing queued, the pull still says why the learnings checkout is missing. + const firstPull = await runCLI(['pull'], projectRoot, home); + // Once, however many steps of the pull ran into it. + expect(firstPull.output.split(`${oldCheckout} still has teamai-learnings checked out`).length - 1, firstPull.output).toBe(1); + expect(fs.readFileSync(stray, 'utf8')).toBe('unsaved\n'); + + fs.mkdirSync(path.join(projectRoot, '.teamai', 'pending-learnings'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.teamai', 'pending-learnings', 'legacy-main-2026-01-01-aaaaaa.md'), + '# Legacy note queued in the main checkout\n', + ); + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + fs.mkdirSync(path.join(worktree, '.teamai', 'pending-learnings'), { recursive: true }); + fs.writeFileSync( + path.join(worktree, '.teamai', 'pending-learnings', 'legacy-wt-2026-01-01-bbbbbb.md'), + '# Legacy note queued in the worktree\n', + ); + + // The pull moves the main checkout's queue into the partition, then stops + // at the dirty old checkout and says what to do with it. + const pullMain = await runCLI(['pull'], projectRoot, home); + // Once in full; the queue summary gives the short reason. + expect(pullMain.output.split(`${oldCheckout} still has teamai-learnings checked out`).length - 1, pullMain.output).toBe(1); + expect(pullMain.output).toContain(`Commit or move the uncommitted changes in ${oldCheckout}`); + expect(pullMain.output).toContain('or delete it by hand, which loses those uncommitted changes'); + expect(pullMain.output).toContain('an old teamai-learnings checkout is in the way; see the warning above'); + expect(fs.readFileSync(stray, 'utf8')).toBe('unsaved\n'); + expect(fs.existsSync(path.join(projectRoot, '.teamai', 'pending-learnings'))).toBe(false); + expect(partitionQueue(project)).toContain('legacy-main-2026-01-01-aaaaaa.md'); + + // Maintenance stops there too: the shared checkout it would write into does + // not exist, and the next publish would delete what it wrote. + const learning = path.join(projectRoot, '.teamai', 'learnings', 'kb-note.md'); + fs.writeFileSync(learning, '---\ntitle: kb-note\n---\nA learning with votes.\n'); + const votes = path.join(partitionOf(project), 'reports-wt', 'votes', 'ci-808.yaml'); + fs.mkdirSync(path.dirname(votes), { recursive: true }); + fs.writeFileSync(votes, [ + 'version: 2', + 'votes:', + ' kb-note:', + ' recalled_count: 5', + ' upvoted_count: 5', + ` last_recalled_at: ${new Date().toISOString()}`, + 'deltas: {}', + '', + ].join('\n')); + const oldHead = git(['rev-parse', 'HEAD'], oldCheckout); + const maintenance = await runCLI(['recall', 'maintenance', '--confidence-writeback'], projectRoot, home); + expect(maintenance.code, maintenance.output).toBe(1); + expect(maintenance.output.split(`${oldCheckout} still has teamai-learnings checked out`).length - 1, maintenance.output).toBe(1); + expect(maintenance.output).not.toContain('Updated confidence'); + expect(fs.existsSync(path.join(partitionOf(project), 'learnings-wt'))).toBe(false); + expect(fs.readFileSync(learning, 'utf8')).toBe('---\ntitle: kb-note\n---\nA learning with votes.\n'); + expect(git(['status', '--porcelain'], oldCheckout)).toBe('?? draft.txt\n'); + expect(git(['rev-parse', 'HEAD'], oldCheckout)).toBe(oldHead); + fs.rmSync(learning); + fs.rmSync(votes); + + // A contribute meanwhile stays queued: no pull publishes it until the old checkout is dealt with. + const blocked = await runCLI( + ['contribute', '--title', 'blocked-note', '--file', note(project, 'blocked', 'Note while the old checkout is in the way')], + projectRoot, + home, + ); + expect(blocked.output).toContain( + 'Saved locally (an old teamai-learnings checkout is in the way; see the warning above). It stays queued and recallable here, ' + + 'but no `teamai pull` can publish it until that checkout is dealt with: do what the refusal says, then run `teamai pull`.', + ); + expect(blocked.output).not.toContain('the next `teamai pull` publishes it'); + + // Once the old checkout is clean, a contribute in the worktree, with no + // pull first, removes it, moves the worktree's own old queue into the + // shared one, and publishes all of it. + fs.rmSync(stray); + const fromWorktree = await runCLI( + ['contribute', '--title', 'wt-note', '--file', note(project, 'wt', 'Note contributed from the worktree')], + worktree, + home, + ); + expect(fromWorktree.code, fromWorktree.output).toBe(0); + expect(fromWorktree.output).not.toContain('Saved locally'); + expect(fs.existsSync(oldCheckout)).toBe(false); + expect(fs.existsSync(path.join(worktree, '.teamai', 'pending-learnings'))).toBe(false); + expect(published(project).some((f) => f.includes('wt-note-')), published(project).join('\n')).toBe(true); + expect(published(project)).toContain('learnings/legacy-main-2026-01-01-aaaaaa.md'); + expect(published(project)).toContain('learnings/legacy-wt-2026-01-01-bbbbbb.md'); + expect(published(project).some((f) => f.includes('blocked-note-')), published(project).join('\n')).toBe(true); + + // Removing the worktree loses nothing: every queued learning reached origin. + // --force: the contribute brought its old .teamai/.gitignore up to date. + git(['worktree', 'remove', '--force', worktree], projectRoot); + expect(partitionQueue(project)).toEqual([]); + expect(published(project)).toContain('learnings/legacy-wt-2026-01-01-bbbbbb.md'); + }); + + it("refuses a learnings checkout another repository left in the partition, and publishes nothing to it", async () => { + const project = setUpProject(); + const { sandbox, home, projectRoot } = project; + + // A team repo with its own teamai-learnings branch, as a git-mode install uses. + const teamRemote = path.join(sandbox, 'team.git'); + const teamSeed = path.join(sandbox, 'team-seed'); + git(['init', '-q', '--bare', teamRemote], sandbox); + git(['clone', '-q', teamRemote, teamSeed], sandbox); + git(['checkout', '-q', '--orphan', 'teamai-learnings'], teamSeed); + fs.mkdirSync(path.join(teamSeed, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'learnings', '.gitkeep'), ''); + // The other team's learning, which this project must never index or touch. + // Dated today, so the weekly digest would list it. + const foreignNote = `foreign-note-${new Date().toISOString().slice(0, 10)}-cccccc.md`; + fs.writeFileSync(path.join(teamSeed, 'learnings', foreignNote), '# Foreign note from the other team\n'); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'learnings branch'], teamSeed); + git(['push', '-q', 'origin', 'teamai-learnings'], teamSeed); + + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const projects = path.join(home, '.teamai', 'projects'); + const partition = path.join(projects, fs.readdirSync(projects)[0]); + const checkout = path.join(partition, 'learnings-wt'); + + // What the project's earlier git-mode install left in the same partition: + // the team clone, with its own checkout of teamai-learnings where self mode + // keeps the business repo's. + git(['worktree', 'remove', checkout], projectRoot); + const teamClone = path.join(partition, 'team-repo'); + git(['clone', '-q', teamRemote, teamClone], sandbox); + git(['fetch', '-q', 'origin', 'teamai-learnings:teamai-learnings'], teamClone); + git(['worktree', 'add', '-q', checkout, 'teamai-learnings'], teamClone); + + const contribute = await runCLI( + ['contribute', '--title', 'self-note', '--file', note(project, 'self', 'Note contributed in self mode')], + projectRoot, + home, + ); + // Said once in full; "Saved locally" gives the short reason. + expect(contribute.output.split(`${checkout} is a teamai-learnings checkout of ${teamClone}`).length - 1, contribute.output).toBe(1); + expect(contribute.output).toContain(`git -C ${teamClone} worktree remove ${checkout}`); + expect(contribute.output).toContain('Saved locally (the teamai-learnings checkout belongs to another repository; see the warning above)'); + const teamLearnings = git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], teamRemote); + expect(teamLearnings).not.toContain('self-note-'); + expect(fs.existsSync(checkout)).toBe(true); + expect(partitionQueue(project).some((f) => f.startsWith('self-note-'))).toBe(true); + // Neither contribute's index rebuild nor recall's own serves the other team's learning. + // This project's own knowledge, the queued learning included, stays recallable. + const recallAfterContribute = await runCLI(['recall', 'note'], projectRoot, home); + expect(recallAfterContribute.output).not.toContain('foreign-note-'); + expect(recallAfterContribute.output).toContain('self-note-'); + // With no index at all, recall builds one itself. + const workspaces = path.join(partition, 'workspaces'); + for (const id of fs.existsSync(workspaces) ? fs.readdirSync(workspaces) : []) { + fs.rmSync(path.join(workspaces, id, 'search-index.json'), { force: true }); + } + const recallRebuilt = await runCLI(['recall', 'note'], projectRoot, home); + expect(recallRebuilt.output).not.toContain('foreign-note-'); + expect(recallRebuilt.output).toContain('self-note-'); + + // A pull runs into it at several steps and still says it once in full. + const pull = await runCLI(['pull'], projectRoot, home); + expect(pull.output.split(`${checkout} is a teamai-learnings checkout of`).length - 1, pull.output).toBe(1); + expect(git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], teamRemote)).not.toContain('self-note-'); + const recallAfterPull = await runCLI(['recall', 'note'], projectRoot, home); + expect(recallAfterPull.output).not.toContain('foreign-note-'); + expect(recallAfterPull.output).toContain('self-note-'); + + // The weekly digest lists this team's learnings, not the other team's. It + // only gets that far with some team usage on this project's reports branch. + const reportsCheckout = path.join(partition, 'reports-wt'); + fs.mkdirSync(path.join(reportsCheckout, 'stats'), { recursive: true }); + fs.writeFileSync(path.join(reportsCheckout, 'stats', 'ci-808.yaml'), 'username: ci-808\nskills: {}\n'); + git(['add', 'stats'], reportsCheckout); + git(['commit', '-q', '-m', 'stats'], reportsCheckout); + git(['push', '-q', 'origin', 'teamai-reports'], reportsCheckout); + const digest = await runCLI(['digest'], projectRoot, home); + expect(digest.output).not.toContain('No team usage data'); + expect(digest.output).not.toContain('Failed to generate digest'); + expect(digest.output).toContain('Active members: 1'); + expect(digest.output).not.toMatch(/foreign note/i); + + // Maintenance rewrites learnings, so it stops instead of touching the other team's checkout. + const before = git(['rev-parse', 'HEAD'], checkout); + const maintenance = await runCLI(['recall', 'maintenance', '--prune', '--threshold', '1'], projectRoot, home); + expect(maintenance.code, maintenance.output).not.toBe(0); + expect(maintenance.output).toContain(`${checkout} is a teamai-learnings checkout of ${teamClone}`); + // Said once, as a message, not as a crash. + expect(maintenance.output.split(`${checkout} is a teamai-learnings checkout of`).length - 1, maintenance.output).toBe(1); + expect(maintenance.output).not.toContain('at refuseForeignCheckout'); + expect(fs.existsSync(path.join(checkout, 'learnings', foreignNote))).toBe(true); + expect(git(['status', '--porcelain'], checkout)).toBe(''); + expect(git(['rev-parse', 'HEAD'], checkout)).toBe(before); + + // The same for the reports checkout: the other team's roster is not listed, + // and the command stops with the refusal, not a crash. + git(['checkout', '-q', '--orphan', 'teamai-reports'], teamSeed); + git(['rm', '-rfq', '.'], teamSeed); + fs.mkdirSync(path.join(teamSeed, 'members'), { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'members', 'other-team-member.yaml'), 'username: other-team-member\n'); + // The other team's upvotes under this member's name: of its own doc, and of + // a file named like this project's queued learning. + const selfNote = partitionQueue(project).find((f) => f.startsWith('self-note-')); + if (!selfNote) throw new Error('self-note is not queued'); + fs.mkdirSync(path.join(teamSeed, 'votes'), { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'votes', 'ci-808.yaml'), [ + 'version: 2', + 'votes:', + ' foreign-doc:', + ' recalled_count: 1', + ' upvoted_count: 1', + // Votes key a learning by its filename without `.md`. + ` ${selfNote.replace(/\.md$/, '')}:`, + ' recalled_count: 3', + ' upvoted_count: 3', + 'deltas: {}', + '', + ].join('\n')); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'reports branch'], teamSeed); + git(['push', '-q', 'origin', 'teamai-reports'], teamSeed); + git(['worktree', 'remove', '--force', reportsCheckout], projectRoot); + git(['fetch', '-q', 'origin', 'teamai-reports:teamai-reports'], teamClone); + git(['worktree', 'add', '-q', reportsCheckout, 'teamai-reports'], teamClone); + for (const args of [['members'], ['projects', 'members', 'some-project']]) { + const run = await runCLI(args, projectRoot, home); + expect(run.code, run.output).toBe(1); + expect(run.output.split(`${reportsCheckout} is a teamai-reports checkout of`).length - 1, run.output).toBe(1); + expect(run.output).not.toContain('at refuseForeignCheckout'); + expect(run.output).not.toContain('other-team-member'); + } + // A downvote does not count the other team's votes as this team's. + const downvote = await runCLI(['recall', 'feedback', '--negative', 'foreign-doc'], projectRoot, home); + expect(downvote.output).toContain('Document not found in votes: foreign-doc'); + expect(downvote.output).not.toContain('Negative signal recorded'); + + // Nor does an index build: contribute's, then recall's own. + const indexPath = path.join(partition, 'workspaces', workspaceId(projectRoot), 'search-index.json'); + const selfNoteVotes = (): number | undefined => { + const index: unknown = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + const entries = index && typeof index === 'object' && 'entries' in index && Array.isArray(index.entries) ? index.entries : []; + const entry: unknown = entries.find((e: unknown) => e && typeof e === 'object' && 'filename' in e && e.filename === selfNote); + return entry && typeof entry === 'object' && 'votes' in entry && typeof entry.votes === 'number' ? entry.votes : undefined; + }; + await runCLI(['contribute', '--title', 'another-note', '--file', note(project, 'another', 'Another note')], projectRoot, home); + expect(selfNoteVotes()).toBe(0); + fs.rmSync(indexPath, { force: true }); + await runCLI(['recall', 'note'], projectRoot, home); + expect(selfNoteVotes()).toBe(0); + }); + + it('refuses and keeps a learnings checkout whose repository is gone, and indexes none of it', async () => { + const project = setUpProject(); + const { sandbox, home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const partition = partitionOf(project); + const checkout = path.join(partition, 'learnings-wt'); + + // A checkout of a repository that was moved or deleted since: its .git + // points at a git dir that no longer exists, and it holds uncommitted work. + git(['worktree', 'remove', checkout], projectRoot); + const gone = path.join(sandbox, 'moved-repo', '.git', 'worktrees', 'learnings-wt'); + fs.mkdirSync(path.join(checkout, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(checkout, '.git'), `gitdir: ${gone}\n`); + const orphan = path.join(checkout, 'learnings', 'quokka-note-2026-01-01-eeeeee.md'); + fs.writeFileSync(orphan, '# Quokka note nobody committed\n'); + const refusal = `${checkout} is a teamai-learnings checkout teamai cannot show to be ${projectRoot}'s`; + + for (const id of fs.readdirSync(path.join(partition, 'workspaces'))) { + fs.rmSync(path.join(partition, 'workspaces', id, 'search-index.json'), { force: true }); + } + const recall = await runCLI(['recall', 'quokka', 'note'], projectRoot, home); + expect(recall.output).not.toContain('quokka-note-'); + + const contribute = await runCLI( + ['contribute', '--title', 'kept-note', '--file', note(project, 'kept', 'Note contributed beside a checkout of a gone repository')], + projectRoot, + home, + ); + expect(contribute.output.split(refusal).length - 1, contribute.output).toBe(1); + expect(contribute.output).toContain('Saved locally'); + expect(fs.readFileSync(orphan, 'utf8')).toBe('# Quokka note nobody committed\n'); + expect(partitionQueue(project).some((f) => f.startsWith('kept-note-'))).toBe(true); + const recallAfter = await runCLI(['recall', 'quokka', 'note'], projectRoot, home); + expect(recallAfter.output).not.toContain('quokka-note-'); + }); + + it('stops maintenance and promote while another command holds the learnings or reports lock, writing nothing', async () => { + const project = setUpProject(); + const { home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const partition = partitionOf(project); + const checkout = path.join(partition, 'learnings-wt'); + const lock = path.join(partition, '.learnings-lock'); + const busy = `The learnings checkout is locked: another teamai command may be updating it, or its lock at ${lock} could not be created. ` + + `Nothing was changed. Run this again when the other command finishes; if this keeps happening, check that ${partition} is writable.`; + + // A live holder: this test's own process, as another teamai command would be. + fs.writeFileSync( + lock, + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'issue-808-e2e' }), + ); + const before = git(['rev-parse', 'HEAD'], checkout); + for (const args of [['recall', 'maintenance', '--confidence-writeback'], ['recall', 'promote']]) { + const run = await runCLI(args, projectRoot, home); + expect(run.code, run.output).toBe(1); + expect(run.output.split(busy).length - 1, run.output).toBe(1); + expect(git(['status', '--porcelain'], checkout)).toBe(''); + expect(git(['rev-parse', 'HEAD'], checkout)).toBe(before); + } + + // Maintenance reads votes from the reports checkout, which is just as unchecked under its lock. + const reportsLock = path.join(partition, '.reports-lock'); + const reportsBusy = `The reports checkout is locked: another teamai command may be updating it, or its lock at ${reportsLock} could not be created. ` + + `Nothing was changed. Run this again when the other command finishes; if this keeps happening, check that ${partition} is writable.`; + fs.renameSync(lock, reportsLock); + for (const args of [['recall', 'maintenance', '--confidence-writeback'], ['recall', 'promote']]) { + const run = await runCLI(args, projectRoot, home); + expect(run.code, run.output).toBe(1); + expect(run.output.split(reportsBusy).length - 1, run.output).toBe(1); + expect(git(['status', '--porcelain'], checkout)).toBe(''); + expect(git(['rev-parse', 'HEAD'], checkout)).toBe(before); + } + fs.renameSync(reportsLock, lock); + + // The holder may be creating the checkout: maintenance leaves the path to it. + git(['worktree', 'remove', checkout], projectRoot); + const creating = await runCLI(['recall', 'maintenance', '--confidence-writeback'], projectRoot, home); + expect(creating.code, creating.output).toBe(1); + expect(creating.output.split(busy).length - 1, creating.output).toBe(1); + expect(fs.existsSync(checkout)).toBe(false); + }); + + it('sets aside a git-mode queue when init switches the project to single-repo mode, and uninstall lists every queue', async () => { + const project = setUpProject(); + const { home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const partition = partitionOf(project); + + // An earlier git-mode install of this project, with a learning still + // queued for its own team repository. + fs.writeFileSync(path.join(partition, 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(partition, 'team-repo')}`, + ' remote: https://git.example.com/other/team.git', + ' kind: git', + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${projectRoot}`, + '', + ].join('\n')); + const queue = path.join(partition, 'pending-learnings'); + const otherTeams = 'other-team-note-2026-01-01-aaaaaa.md'; + fs.mkdirSync(queue, { recursive: true }); + fs.writeFileSync(path.join(queue, otherTeams), '# Queued for the other team\n'); + // Its search index, and a per-checkout one: both built from that repository. + const rootIndex = path.join(partition, 'search-index.json'); + const checkoutIndex = path.join(partition, 'workspaces', workspaceId(projectRoot), 'search-index.json'); + fs.writeFileSync(rootIndex, '{"version":6,"entries":[]}\n'); + fs.mkdirSync(path.dirname(checkoutIndex), { recursive: true }); + fs.writeFileSync(checkoutIndex, '{"version":6,"entries":[]}\n'); + + // init needs no network here: an unreachable origin fails the GitLab probe + // at once, and the member registration push is best effort. + git(['config', 'user.name', 'TeamAI CI'], projectRoot); + git(['config', 'user.email', 'ci@teamai.test'], projectRoot); + git(['remote', 'set-url', 'origin', 'http://127.0.0.1:9/team/project.git'], projectRoot); + const init = await runCLI(['init', '--self', '--force'], projectRoot, home); + expect(init.code, init.output).toBe(0); + + const asideDir = path.join(partition, 'pending-learnings.git'); + expect(fs.readFileSync(path.join(asideDir, otherTeams), 'utf8')).toBe('# Queued for the other team\n'); + expect(fs.existsSync(path.join(queue, otherTeams))).toBe(false); + expect(init.output).toContain(`Set aside 1 queued learning(s) from the previous git install in ${asideDir}`); + // No index built from the previous repository survives the switch. + expect(fs.existsSync(rootIndex)).toBe(false); + expect(fs.existsSync(checkoutIndex)).toBe(false); + + // Uninstall names every queue it would delete, with its count, before it asks. + fs.mkdirSync(queue, { recursive: true }); + fs.writeFileSync(path.join(queue, 'mine-2026-01-01-bbbbbb.md'), '# Mine\n'); + const uninstall = await runCLI(['uninstall'], projectRoot, home); + expect(uninstall.output).toContain(`1 unpublished learning(s) in ${queue}`); + expect(uninstall.output).toContain(`1 unpublished learning(s) in ${asideDir}`); + expect(fs.existsSync(partition)).toBe(true); + }); + + it("sets aside an unmigrated worktree's old queue once another checkout switched the project to a git team repo, and publishes none of it there", async () => { + const project = setUpProject(); + const { sandbox, home, projectRoot, worktree } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + + // The linked worktree's own pre-partition install, with a learning queued + // for the business repo. + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + const knowledge = path.join(worktree, '.teamai'); + fs.writeFileSync(path.join(knowledge, 'config.yaml'), [ + 'repo:', + ` localPath: ${knowledge}`, + ` remote: ${project.remote}`, + ' kind: self', + ` businessRepoRoot: ${worktree}`, + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${worktree}`, + '', + ].join('\n')); + const oldQueued = 'old-self-note-2026-01-01-ffffff.md'; + fs.mkdirSync(path.join(knowledge, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(knowledge, 'pending-learnings', oldQueued), '# Queued for the business repo\n'); + + // A git team repo behind an HTTPS URL, which git rewrites to a local bare repo. + const teamUrl = 'https://git.example.com/team/team.git'; + const teamRemote = path.join(sandbox, 'team.git'); + const teamSeed = path.join(sandbox, 'team-seed'); + fs.mkdirSync(teamSeed, { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'teamai.yaml'), [ + 'team: issue-808-team', + `repo: ${teamUrl}`, + 'provider: git', + 'reviewers: []', + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], teamSeed); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'team repo'], teamSeed); + git(['clone', '-q', '--bare', teamSeed, teamRemote], sandbox); + fs.writeFileSync(path.join(home, '.gitconfig'), `[url "${teamRemote}"]\n\tinsteadOf = ${teamUrl}\n`); + + // The main checkout switches the project to the team repo. + const init = await runCLI(['init', teamUrl, '--scope', 'project', '--force'], projectRoot, home); + expect(init.code, init.output).toBe(0); + const partition = partitionOf(project); + // A git install: its config names the team repo and no kind. + const partitionConfig = fs.readFileSync(path.join(partition, 'config.yaml'), 'utf8'); + expect(partitionConfig).toContain(`remote: ${teamUrl}`); + expect(partitionConfig).not.toContain('kind:'); + // The self install's side-branch checkouts are in the way of the team + // repo's; remove them, as teamai says to. + for (const dir of ['learnings-wt', 'reports-wt']) git(['worktree', 'remove', path.join(partition, dir)], projectRoot); + + // The worktree never migrated: its checkout config still says self. + const contribute = await runCLI( + ['contribute', '--title', 'team-note', '--file', note(project, 'team', 'Note contributed to the team repo')], + worktree, + home, + ); + expect(contribute.code, contribute.output).toBe(0); + const teamLearnings = git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], teamRemote); + expect(teamLearnings).toContain('team-note-'); + expect(teamLearnings).not.toContain(oldQueued); + expect(partitionQueue(project)).not.toContain(oldQueued); + // Kept beside the team repo's queue, where uninstall lists it. + const aside = path.join(partition, 'pending-learnings.self'); + expect(fs.readFileSync(path.join(aside, oldQueued), 'utf8')).toBe('# Queued for the business repo\n'); + expect(contribute.output).toContain(`Set aside 1 queued learning(s) from the previous self install in ${aside}`); + expect(fs.existsSync(path.join(knowledge, 'pending-learnings'))).toBe(false); + }); + + it('stops maintenance and promote when the learnings checkout cannot be created, writing nothing', async () => { + const project = setUpProject(); + const { sandbox, home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const partition = partitionOf(project); + const checkout = path.join(partition, 'learnings-wt'); + + // teamai-learnings checked out where teamai does not look: git will not + // check it out a second time. + git(['worktree', 'remove', checkout], projectRoot); + const elsewhere = path.join(sandbox, 'elsewhere-learnings'); + git(['worktree', 'add', '-q', elsewhere, 'teamai-learnings'], projectRoot); + const elsewhereHead = git(['rev-parse', 'HEAD'], elsewhere); + + const learning = path.join(projectRoot, '.teamai', 'learnings', 'kb-note.md'); + fs.writeFileSync(learning, '---\ntitle: kb-note\n---\nA learning with votes.\n'); + const votes = path.join(partition, 'reports-wt', 'votes', 'ci-808.yaml'); + fs.mkdirSync(path.dirname(votes), { recursive: true }); + fs.writeFileSync(votes, [ + 'version: 2', + 'votes:', + ' kb-note:', + ' recalled_count: 5', + ' upvoted_count: 5', + ` last_recalled_at: ${new Date().toISOString()}`, + 'deltas: {}', + '', + ].join('\n')); + + for (const args of [['recall', 'maintenance', '--confidence-writeback'], ['recall', 'promote']]) { + const run = await runCLI(args, projectRoot, home); + expect(run.code, run.output).toBe(1); + expect(run.output).toContain('The learnings checkout could not be set up: '); + expect(run.output).toContain(elsewhere); + expect(run.output).toContain('Nothing was changed.'); + expect(run.output).not.toContain('Updated confidence'); + expect(fs.existsSync(checkout)).toBe(false); + expect(fs.readFileSync(learning, 'utf8')).toBe('---\ntitle: kb-note\n---\nA learning with votes.\n'); + expect(git(['status', '--porcelain'], elsewhere)).toBe(''); + expect(git(['rev-parse', 'HEAD'], elsewhere)).toBe(elsewhereHead); + } + }); + + it("sets aside the queue an older self install left in the checkout once the project uses a team repo, so contribute and init still run", async () => { + const project = setUpProject(); + const { sandbox, home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + + const teamUrl = 'https://git.example.com/team/team.git'; + const teamRemote = path.join(sandbox, 'team.git'); + const teamSeed = path.join(sandbox, 'team-seed'); + fs.mkdirSync(teamSeed, { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'teamai.yaml'), [ + 'team: issue-808-team', + `repo: ${teamUrl}`, + 'provider: git', + 'reviewers: []', + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], teamSeed); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'team repo'], teamSeed); + git(['clone', '-q', '--bare', teamSeed, teamRemote], sandbox); + fs.writeFileSync(path.join(home, '.gitconfig'), `[url "${teamRemote}"]\n\tinsteadOf = ${teamUrl}\n`); + const init = await runCLI(['init', teamUrl, '--scope', 'project', '--force'], projectRoot, home); + expect(init.code, init.output).toBe(0); + const partition = partitionOf(project); + for (const dir of ['learnings-wt', 'reports-wt']) git(['worktree', 'remove', path.join(partition, dir)], projectRoot); + + // What an older teamai left: a self install whose config was already in + // the partition queued in the checkout, and its init switched the project + // to the team repo without moving that queue. + const oldQueue = path.join(projectRoot, '.teamai', 'pending-learnings'); + const oldQueued = 'old-self-note-2026-01-01-aaaaaa.md'; + fs.mkdirSync(oldQueue, { recursive: true }); + fs.writeFileSync(path.join(oldQueue, oldQueued), '# Queued for the business repo\n'); + + const contribute = await runCLI( + ['contribute', '--title', 'team-note', '--file', note(project, 'team', 'Note contributed to the team repo')], + projectRoot, + home, + ); + expect(contribute.code, contribute.output).toBe(0); + const aside = path.join(partition, 'pending-learnings.self'); + expect(fs.readFileSync(path.join(aside, oldQueued), 'utf8')).toBe('# Queued for the business repo\n'); + expect(contribute.output).toContain(`Set aside 1 queued learning(s) from the previous self install in ${aside}`); + expect(fs.existsSync(oldQueue)).toBe(false); + const teamLearnings = git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], teamRemote); + expect(teamLearnings).toContain('team-note-'); + expect(teamLearnings).not.toContain(oldQueued); + + const reinit = await runCLI(['init', teamUrl, '--scope', 'project', '--force'], projectRoot, home); + expect(reinit.code, reinit.output).toBe(0); + }); + + it("names the checkout's config.yaml it cannot read when a learning queued beside it stops contribute", async () => { + const project = setUpProject(); + const { home, projectRoot } = project; + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + + const legacyDir = path.join(projectRoot, '.teamai'); + const legacyConfig = path.join(legacyDir, 'config.yaml'); + fs.writeFileSync(legacyConfig, 'repo: [not a config\n'); + const queued = 'kept-note-2026-01-01-aaaaaa.md'; + fs.mkdirSync(path.join(legacyDir, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'pending-learnings', queued), '# Queued beside a broken config\n'); + + const contribute = await runCLI( + ['contribute', '--title', 'blocked', '--file', note(project, 'blocked', 'Blocked note')], + projectRoot, + home, + ); + expect(contribute.code, contribute.output).toBe(1); + expect(contribute.output).toContain(`${legacyConfig} cannot be read`); + expect(contribute.output).toContain('see the warning above'); + expect(fs.readdirSync(path.join(legacyDir, 'pending-learnings'))).toEqual([queued]); + }); +}); + + +/** + * A git team repo, and a business repo with a bare origin and a linked + * worktree. Neither checkout has teamai yet; writeGitInstall gives one the + * install an older teamai left. + */ +function setUpGitInstall(): Project & { teamRemote: string } { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-issue808-git-e2e-'))); + sandboxes.push(sandbox); + const install = { + sandbox, + home: path.join(sandbox, 'home'), + remote: path.join(sandbox, 'origin.git'), + projectRoot: path.join(sandbox, 'project'), + worktree: path.join(sandbox, 'project-wt'), + teamRemote: path.join(sandbox, 'team.git'), + }; + fs.mkdirSync(install.home, { recursive: true }); + + const teamSeed = path.join(sandbox, 'team-seed'); + fs.mkdirSync(teamSeed, { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'teamai.yaml'), [ + 'team: issue-808-git', + `repo: ${install.teamRemote}`, + 'provider: git', + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], teamSeed); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'team repo'], teamSeed); + git(['clone', '-q', '--bare', teamSeed, install.teamRemote], sandbox); + + git(['init', '-q', '--bare', install.remote], sandbox); + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], install.remote); + fs.mkdirSync(install.projectRoot, { recursive: true }); + fs.writeFileSync(path.join(install.projectRoot, 'README.md'), '# Business repo\n'); + git(['init', '-q', '-b', 'main'], install.projectRoot); + git(['add', '-A'], install.projectRoot); + git(['commit', '-q', '-m', 'project'], install.projectRoot); + git(['remote', 'add', 'origin', install.remote], install.projectRoot); + git(['push', '-q', '-u', 'origin', 'main'], install.projectRoot); + return install; +} + +/** + * The git-mode install an older teamai left in a checkout's `.teamai/`: a + * clone of the team repo and the config naming it. Returns that directory. + */ +function writeGitInstall(install: { teamRemote: string }, checkout: string): string { + const legacyDir = path.join(checkout, '.teamai'); + const clone = path.join(legacyDir, 'team-repo'); + fs.mkdirSync(legacyDir, { recursive: true }); + git(['clone', '-q', install.teamRemote, clone], checkout); + fs.writeFileSync(path.join(legacyDir, 'config.yaml'), [ + 'repo:', + ` localPath: ${clone}`, + ` remote: ${install.teamRemote}`, + ' kind: git', + 'username: ci-808', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${checkout}`, + '', + ].join('\n')); + fs.writeFileSync(path.join(legacyDir, 'state.json'), JSON.stringify({ lastPullRev: null })); + return legacyDir; +} + +/** A team repo `init` can take by its HTTPS URL, which git rewrites to a local bare repo. */ +function serveTeamRepo(install: Project, name: string): { url: string; bare: string } { + const url = `https://git.example.com/team/${name}.git`; + const bare = path.join(install.sandbox, `${name}.git`); + const seed = path.join(install.sandbox, `${name}-seed`); + fs.mkdirSync(seed, { recursive: true }); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), [`team: ${name}`, `repo: ${url}`, 'provider: git', 'reviewers: []', ''].join('\n')); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'team repo'], seed); + git(['clone', '-q', '--bare', seed, bare], install.sandbox); + fs.appendFileSync(path.join(install.home, '.gitconfig'), `[url "${bare}"]\n\tinsteadOf = ${url}\n`); + return { url, bare }; +} + +describe('a checkout an older git-mode install left (#808)', () => { + beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + }); + + afterEach(() => { + for (const sandbox of sandboxes.splice(0)) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it("stops contribute while the partition has no config it can read, so nothing is queued in the checkout", async () => { + const install = setUpGitInstall(); + const { home, projectRoot } = install; + const legacyDir = writeGitInstall(install, projectRoot); + const partition = path.join(home, '.teamai', 'projects', projectSlug(projectRoot)); + + // A partition moved aside by hand, then one whose config does not parse. + for (const config of [null, 'repo: "unterminated\n']) { + fs.rmSync(partition, { recursive: true, force: true }); + fs.mkdirSync(partition, { recursive: true }); + if (config !== null) fs.writeFileSync(path.join(partition, 'config.yaml'), config); + + const run = await runCLI( + ['contribute', '--title', 'kept-note', '--file', note(install, 'kept', 'Note contributed beside a broken partition')], + projectRoot, + home, + ); + expect(run.code, run.output).toBe(1); + expect(run.output).toContain("teamai could not move this checkout's data into the project's shared data directory ("); + expect(run.output).toContain('Nothing was saved.'); + expect(fs.existsSync(path.join(legacyDir, 'pending-learnings'))).toBe(false); + expect(fs.existsSync(path.join(legacyDir, 'config.yaml'))).toBe(true); + } + }); + + it("keeps a linked worktree's queue when another checkout migrated first, so removing the worktree loses nothing", async () => { + const install = setUpGitInstall(); + const { home, projectRoot, worktree } = install; + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + const queued = new Map([ + [writeGitInstall(install, projectRoot), 'root-note-2026-01-01-aaaaaa.md'], + [writeGitInstall(install, worktree), 'wt-note-2026-01-01-bbbbbb.md'], + ]); + for (const [legacyDir, name] of queued) { + fs.mkdirSync(path.join(legacyDir, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'pending-learnings', name), `# ${name}\n`); + } + + // The main checkout builds the partition; the worktree finds it built. + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + const second = await runCLI(['pull'], worktree, home); + expect(second.code, second.output).toBe(0); + expect(fs.existsSync(path.join(worktree, '.teamai'))).toBe(false); + git(['worktree', 'remove', '--force', worktree], projectRoot); + + // Each learning is still queued in the partition, or on the team repo. + const kept = [ + ...partitionQueue(install), + ...git(['log', '--all', '--name-only', '--format='], install.teamRemote).split('\n').map((f) => path.basename(f)), + ]; + for (const name of queued.values()) expect(kept, kept.join('\n')).toContain(name); + }); + + it('lets a checkout pull and contribute after another checkout switched the project to single-repo mode, keeping the knowledge and setting the old queue aside', async () => { + const install = setUpGitInstall(); + const { home, remote, projectRoot, worktree } = install; + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + writeGitInstall(install, projectRoot); + const legacyDir = writeGitInstall(install, worktree); + const oldQueued = 'old-git-note-2026-01-01-eeeeee.md'; + fs.mkdirSync(path.join(legacyDir, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'pending-learnings', oldQueued), '# Queued for the git team repo\n'); + + // The main checkout switches the project to single-repo mode. init needs + // no network here: an unreachable origin fails the GitLab probe at once. + git(['config', 'user.name', 'TeamAI CI'], projectRoot); + git(['config', 'user.email', 'ci@teamai.test'], projectRoot); + git(['remote', 'set-url', 'origin', 'http://127.0.0.1:9/team/project.git'], projectRoot); + const init = await runCLI(['init', '--self', '--force'], projectRoot, home); + expect(init.code, init.output).toBe(0); + git(['remote', 'set-url', 'origin', remote], projectRoot); + + // The worktree's branch takes the knowledge init committed on main, next + // to its own old install. + git(['merge', '-q', 'main'], worktree); + const teamaiYaml = fs.readFileSync(path.join(worktree, '.teamai', 'teamai.yaml'), 'utf8'); + expect(teamaiYaml).toContain('mode: self'); + expect(fs.existsSync(path.join(legacyDir, 'config.yaml'))).toBe(true); + + const pull = await runCLI(['pull'], worktree, home); + expect(pull.code, pull.output).toBe(0); + const contribute = await runCLI( + ['contribute', '--title', 'self-note', '--file', note(install, 'self', 'Note contributed after the switch')], + worktree, + home, + ); + expect(contribute.code, contribute.output).toBe(0); + + // The knowledge is as main has it, and nothing of the old install is left + // for git to see. + expect(fs.readFileSync(path.join(worktree, '.teamai', 'teamai.yaml'), 'utf8')).toBe(teamaiYaml); + expect(git(['status', '--porcelain', '--', '.teamai'], worktree)).toBe(''); + // The old install is kept, out of git's sight. + expect(fs.existsSync(path.join(`${legacyDir}.bak`, 'config.yaml'))).toBe(true); + expect(fs.existsSync(path.join(`${legacyDir}.bak`, 'team-repo', '.git'))).toBe(true); + // Its queue is set aside, not published to the business repo. + const aside = path.join(home, '.teamai', 'projects', projectSlug(projectRoot), 'pending-learnings.git'); + expect(fs.readFileSync(path.join(aside, oldQueued), 'utf8')).toBe('# Queued for the git team repo\n'); + expect(pull.output).toContain(`Set aside 1 queued learning(s) from the previous git install in ${aside}`); + expect(published(install).some((f) => f.includes('self-note-')), published(install).join('\n')).toBe(true); + expect(published(install)).not.toContain(`learnings/${oldQueued}`); + + const again = await runCLI(['pull'], worktree, home); + expect(again.code, again.output).toBe(0); + }); + + it("sets aside the queue when init points the project at another team repo, and the next pull publishes none of it there (#823 item 13)", async () => { + const install = setUpGitInstall(); + const { home, projectRoot } = install; + const teamA = serveTeamRepo(install, 'team-a'); + const teamB = serveTeamRepo(install, 'team-b'); + + const initA = await runCLI(['init', teamA.url, '--scope', 'project', '--force'], projectRoot, home); + expect(initA.code, initA.output).toBe(0); + // Team A rejects the push, so the learning stays queued for it. + const queued = await withPushesRejected({ ...install, remote: teamA.bare }, () => runCLI( + ['contribute', '--title', 'team-a-note', '--file', note(install, 'team-a', 'Note for team A')], + projectRoot, + home, + )); + expect(queued.output).toContain('Saved locally'); + const [name] = partitionQueue(install).filter((f) => f.startsWith('team-a-note-')); + expect(name, partitionQueue(install).join('\n')).toBeDefined(); + + // A search index built from team A's knowledge. + const index = path.join(partitionOf(install), 'search-index.json'); + fs.writeFileSync(index, '{"version":6,"entries":[],"built":"team-a"}\n'); + + // The same team repo written another way changes nothing. + const initSame = await runCLI(['init', teamA.url.replace(/\.git$/, '/'), '--scope', 'project', '--force'], projectRoot, home); + expect(initSame.code, initSame.output).toBe(0); + expect(initSame.output).not.toContain('Set aside'); + expect(partitionQueue(install)).toContain(name); + expect(fs.readFileSync(index, 'utf8')).toContain('"built":"team-a"'); + + const initB = await runCLI(['init', teamB.url, '--scope', 'project', '--force'], projectRoot, home); + expect(initB.code, initB.output).toBe(0); + const partition = partitionOf(install); + const aside = path.join(partition, 'pending-learnings.git-git.example.com-team-team-a'); + expect(fs.readFileSync(path.join(aside, name), 'utf8')).toContain('Note for team A'); + expect(initB.output).toContain(`Set aside 1 queued learning(s) from the previous git install in ${aside}`); + expect(partitionQueue(install)).not.toContain(name); + expect(fs.existsSync(index)).toBe(false); + + const pull = await runCLI(['pull'], projectRoot, home); + expect(pull.code, pull.output).toBe(0); + expect(git(['log', '--all', '--name-only', '--format='], teamB.bare)).not.toContain('team-a-note-'); + }); + + it("sets aside the queue under an unknown owner when init replaces a config it cannot read, and the next pull publishes none of it", async () => { + const install = setUpGitInstall(); + const { home, projectRoot } = install; + const teamA = serveTeamRepo(install, 'team-a'); + const teamB = serveTeamRepo(install, 'team-b'); + + const initA = await runCLI(['init', teamA.url, '--scope', 'project', '--force'], projectRoot, home); + expect(initA.code, initA.output).toBe(0); + const queued = await withPushesRejected({ ...install, remote: teamA.bare }, () => runCLI( + ['contribute', '--title', 'team-a-note', '--file', note(install, 'team-a', 'Note for team A')], + projectRoot, + home, + )); + expect(queued.output).toContain('Saved locally'); + const [name] = partitionQueue(install).filter((f) => f.startsWith('team-a-note-')); + expect(name, partitionQueue(install).join('\n')).toBeDefined(); + + // The config naming team A no longer parses, so nothing says whose queue it is. + const partition = partitionOf(install); + const config = path.join(partition, 'config.yaml'); + fs.writeFileSync(config, 'repo: [not a config\n'); + // A current-schema index built from team A's knowledge, which recall would serve as is. + const index = path.join(partition, 'search-index.json'); + fs.writeFileSync(index, '{"version":6,"entries":[],"built":"team-a"}\n'); + + const initB = await runCLI(['init', teamB.url, '--scope', 'project', '--force'], projectRoot, home); + expect(initB.code, initB.output).toBe(0); + expect(fs.existsSync(index)).toBe(false); + const aside = path.join(partition, 'pending-learnings.unknown'); + expect(fs.readFileSync(path.join(aside, name), 'utf8')).toContain('Note for team A'); + expect(initB.output).toContain(`Set aside 1 queued learning(s) in ${aside}`); + expect(initB.output).toContain(config); + expect(partitionQueue(install)).not.toContain(name); + + const pull = await runCLI(['pull'], projectRoot, home); + expect(pull.code, pull.output).toBe(0); + expect(git(['log', '--all', '--name-only', '--format='], teamB.bare)).not.toContain('team-a-note-'); + }); + + it("refuses and keeps team A's learnings checkout once init reclones team B at the same path, so its uncommitted work stays", async () => { + const install = setUpGitInstall(); + const { home, projectRoot } = install; + const teamA = serveTeamRepo(install, 'team-a'); + const teamB = serveTeamRepo(install, 'team-b'); + + const initA = await runCLI(['init', teamA.url, '--scope', 'project', '--force'], projectRoot, home); + expect(initA.code, initA.output).toBe(0); + const published = await runCLI( + ['contribute', '--title', 'team-a-note', '--file', note(install, 'team-a', 'Note for team A')], + projectRoot, + home, + ); + expect(published.code, published.output).toBe(0); + const partition = partitionOf(install); + const checkout = path.join(partition, 'learnings-wt'); + const draft = path.join(checkout, 'learnings', 'team-a-draft-2026-01-01-aaaaaa.md'); + fs.writeFileSync(draft, '# Team A draft nobody committed\n'); + + const initB = await runCLI(['init', teamB.url, '--scope', 'project', '--force'], projectRoot, home); + expect(initB.code, initB.output).toBe(0); + // The new clone sits where team A's did, and has no registration of the old checkout. + expect(git(['remote', 'get-url', 'origin'], path.join(partition, 'team-repo')).trim()).toBe(teamB.url); + + const refusal = `${checkout} is a teamai-learnings checkout teamai cannot show to be ${path.join(partition, 'team-repo')}'s`; + const contribute = await runCLI( + ['contribute', '--title', 'team-b-note', '--file', note(install, 'team-b', 'Note for team B')], + projectRoot, + home, + ); + expect(contribute.output.split(refusal).length - 1, contribute.output).toBe(1); + expect(contribute.output).toContain('Saved locally'); + expect(fs.readFileSync(draft, 'utf8')).toBe('# Team A draft nobody committed\n'); + expect(git(['log', '--all', '--name-only', '--format='], teamB.bare)).not.toContain('team-a-'); + }); + + it("sets aside a linked worktree's old queue for another team repo, and publishes none of it to this one (#823 item 13)", async () => { + const install = setUpGitInstall(); + const { sandbox, home, projectRoot, worktree } = install; + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], projectRoot); + writeGitInstall(install, projectRoot); + const first = await runCLI(['pull'], projectRoot, home); + expect(first.code, first.output).toBe(0); + + // The worktree's own older install belongs to another team repo. + const otherTeam = path.join(sandbox, 'other-team.git'); + git(['clone', '-q', '--bare', path.join(sandbox, 'team-seed'), otherTeam], sandbox); + const legacyDir = writeGitInstall({ teamRemote: otherTeam }, worktree); + const oldQueued = 'other-team-note-2026-01-01-aaaaaa.md'; + fs.mkdirSync(path.join(legacyDir, 'pending-learnings'), { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'pending-learnings', oldQueued), '# Queued for the other team\n'); + + const pull = await runCLI(['pull'], worktree, home); + expect(pull.code, pull.output).toBe(0); + expect(fs.existsSync(legacyDir)).toBe(false); + const partition = partitionOf(install); + const asides = fs.readdirSync(partition).filter((n) => n.startsWith('pending-learnings.')); + expect(asides).toHaveLength(1); + expect(asides[0]).toMatch(/^pending-learnings\.git-.+-other-team$/); + expect(fs.readFileSync(path.join(partition, asides[0], oldQueued), 'utf8')).toBe('# Queued for the other team\n'); + expect(pull.output).toContain(`Set aside 1 queued learning(s) from the previous git install in ${path.join(partition, asides[0])}`); + expect(partitionQueue(install)).not.toContain(oldQueued); + expect(git(['log', '--all', '--name-only', '--format='], install.teamRemote)).not.toContain(oldQueued); + }); +}); diff --git a/src/__tests__/git-kind-learnings.test.ts b/src/__tests__/git-kind-learnings.test.ts index 068aa54bc..b297c05c1 100644 --- a/src/__tests__/git-kind-learnings.test.ts +++ b/src/__tests__/git-kind-learnings.test.ts @@ -13,6 +13,7 @@ import { LEARNINGS_WORKTREE_DIRNAME, type LocalConfig } from '../types.js'; import { learningsRoots } from '../utils/learnings-roots.js'; import { savePendingLearning, listPendingLearnings } from '../utils/pending-learnings.js'; import { publishQueuedLearnings } from '../utils/learnings-publish.js'; +import { writeInstallConfig } from './helpers/install-config.js'; let tmp: string; let originalHome: string; @@ -22,6 +23,7 @@ beforeEach(() => { originalHome = process.env.HOME ?? ''; process.env.HOME = path.join(tmp, 'home'); fs.mkdirSync(process.env.HOME, { recursive: true }); + writeInstallConfig(gitConfig(path.join(tmp, 'team-repo'))); }); afterEach(() => { @@ -103,6 +105,24 @@ describe('contributing to a repo whose default branch is protected', () => { expect(await listPendingLearnings(config)).toEqual([]); }); + it('publishes nothing with a config init has since switched to another kind (#823 item 11)', async () => { + const { origin, clone } = await seedProtectedOrigin(); + // This command loaded the git config; then init switched the install, and + // the new one queued a learning of its own. + const config = gitConfig(clone); + writeInstallConfig({ ...config, repo: { ...config.repo, kind: 'http' } }); + const theirs = path.join(path.dirname(clone), 'pending-learnings', 'theirs-2026-01-01-zzz999.md'); + fs.mkdirSync(path.dirname(theirs), { recursive: true }); + fs.writeFileSync(theirs, '# queued by the new install'); + + const report = await publishQueuedLearnings(config, 'alice'); + + expect(await refsOf(origin, 'teamai-learnings')).not.toContain('learnings/theirs-2026-01-01-zzz999.md'); + expect(fs.readFileSync(theirs, 'utf8')).toBe('# queued by the new install'); + expect(report.published).toEqual([]); + expect(report.installChanged).toContain(`${path.join(process.env.HOME ?? '', '.teamai', 'config.yaml')} now names a http install, not git`); + }); + it('keeps the queued copy when the learnings branch is refused too', async () => { const { origin, clone } = await seedProtectedOrigin(); const hook = path.join(origin, 'hooks', 'update'); @@ -174,7 +194,9 @@ describe('contributing to a repo whose default branch is protected', () => { const config = gitConfig(clone); await savePendingLearning(config, 'good-2026-01-01-kkk111.md', '# readable'); - const unreadable = await savePendingLearning(config, 'bad-2026-01-01-lll222.md', '# unreadable'); + const queued = await savePendingLearning(config, 'bad-2026-01-01-lll222.md', '# unreadable'); + if (queued.status !== 'saved') throw new Error(`not queued: ${queued.status}`); + const unreadable = queued.path; fs.chmodSync(unreadable, 0o000); try { @@ -239,7 +261,9 @@ describe('contributing to a repo whose default branch is protected', () => { await savePendingLearning(alice, 'from-alice-2026-01-01-hhh888.md', '# alice knows'); await publishQueuedLearnings(alice, 'alice'); - const bobClone = path.join(tmp, 'team-repo-bob'); + // Bob's own partition: beside Alice's clone he would share her queue and + // her checkout of the branch, which belongs to another repository (#808). + const bobClone = path.join(tmp, 'bob', 'team-repo'); await simpleGit().clone(origin, bobClone); await configureGit(bobClone); const bob: LocalConfig = { @@ -302,7 +326,9 @@ describe('contributing to a repo whose default branch is protected', () => { const { origin, clone } = await seedProtectedOrigin(); const alice = gitConfig(clone, 'alice'); - const bobClone = path.join(tmp, 'team-repo-bob'); + // Bob's own partition: beside Alice's clone he would share her queue and + // her checkout of the branch, which belongs to another repository (#808). + const bobClone = path.join(tmp, 'bob', 'team-repo'); await simpleGit().clone(origin, bobClone); await configureGit(bobClone); const bob: LocalConfig = { diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index ed1d12c6c..5839bcfb8 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -215,7 +215,7 @@ describe('git-kind reports branch', () => { expect(logAfter.total).toBe(logBefore.total); }); - it('rebuilds a dangling sibling reports worktree after the clone is removed and re-cloned', async () => { + it('refuses and keeps a dangling sibling reports worktree after the clone is removed and re-cloned, and rebuilds it once moved aside', async () => { const { origin, clone } = await seedBareOrigin(); const cfg = gitConfig(clone, origin); @@ -223,14 +223,20 @@ describe('git-kind reports branch', () => { fs.mkdirSync(path.join(wt, 'members'), { recursive: true }); fs.writeFileSync(path.join(wt, 'members', 'alice.yaml'), 'username: alice\n'); expect(await commitAndPushReports(cfg, '[teamai] Register member: alice', ['members/'])).toBe(true); + fs.writeFileSync(path.join(wt, 'draft.txt'), 'uncommitted\n'); fs.rmSync(clone, { recursive: true, force: true }); await simpleGit().clone(origin, clone); await configureGit(clone); // The sibling husk is still on disk; isGitRepo would return true, but the - // gitdir under the old clone is gone. ensureReportsWorktree must recreate. + // gitdir under the old clone is gone, and a clone at the same path cannot + // show the husk is its own (init reclones another team repo there too). expect(fs.existsSync(wt)).toBe(true); + await expect(ensureReportsWorktree(cfg)).rejects.toThrow(`${wt} is a teamai-reports checkout teamai cannot show to be`); + expect(fs.readFileSync(path.join(wt, 'draft.txt'), 'utf-8')).toBe('uncommitted\n'); + + fs.renameSync(wt, `${wt}.aside`); const rebuilt = await ensureReportsWorktree(cfg); expect(rebuilt).toBe(wt); diff --git a/src/__tests__/helpers/install-config.ts b/src/__tests__/helpers/install-config.ts new file mode 100644 index 000000000..151ab67f3 --- /dev/null +++ b/src/__tests__/helpers/install-config.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; +import { getDataHome, type LocalConfig } from '../../types.js'; + +/** + * Write `config` where its install keeps it, `/config.yaml`. A queue + * write checks that file under the queue lock: a config the migration moved + * away, or one init switched to another kind, saves nothing (#823 item 11). + */ +export function writeInstallConfig(config: LocalConfig): void { + const file = path.join(getDataHome(config), 'config.yaml'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, YAML.stringify({ repo: config.repo, username: config.username, scope: config.scope })); +} diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 01c2466ab..506225af9 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -1015,6 +1015,102 @@ describe('hook-handlers registry', () => { expect(mockJudgeAdoption).toHaveBeenCalledWith('used something', ['doc-b'], { 'doc-b': '/l/doc-b.md' }, expect.arrayContaining(['/tmp'])); }); + describe("votes-judge and the learnings checkout's owner (#808)", () => { + let root: string; + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-judge-808-'))); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + /** + * A self-mode project whose partition holds a teamai-learnings checkout + * registered in `owner`'s git dir, as git writes it: the checkout's `.git` + * file names `/.git/worktrees/learnings-wt`, whose `commondir` leads + * back to `/.git`. No git process runs. + */ + function selfProjectWithCheckoutOf( + owner: 'this project' | 'another repository' | 'this project, pruned' | 'a repository that is gone', + ): { config: LocalConfig; checkoutLearnings: string } { + const business = path.join(root, 'business'); + const other = path.join(root, 'other'); + const dataHome = path.join(root, 'partition'); + fs.mkdirSync(path.join(business, '.git'), { recursive: true }); + fs.mkdirSync(path.join(other, '.git'), { recursive: true }); + const ownerRepo = owner === 'another repository' ? other + : owner === 'a repository that is gone' ? path.join(root, 'gone') + : business; + const registration = path.join(ownerRepo, '.git', 'worktrees', 'learnings-wt'); + // Git keeps the registration while the checkout is live, and prunes it + // once the checkout is gone (a re-cloned repo has none). + if (owner === 'this project' || owner === 'another repository') { + fs.mkdirSync(registration, { recursive: true }); + fs.writeFileSync(path.join(registration, 'commondir'), '../..\n'); + } + const checkout = path.join(dataHome, 'learnings-wt'); + fs.mkdirSync(path.join(checkout, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(checkout, '.git'), `gitdir: ${registration}\n`); + return { + config: { + repo: { localPath: path.join(business, '.teamai'), remote: '', kind: 'self', businessRepoRoot: business }, + username: 'test', + scope: 'project', + projectRoot: business, + dataHome, + additionalRoles: [], + }, + checkoutLearnings: path.join(checkout, 'learnings'), + }; + } + + async function judgeRoots(config: LocalConfig): Promise { + process.env.TEAMAI_UPVOTE_JUDGE = '1'; + mockParseTranscriptForVotes.mockResolvedValue({ + recalledDocIds: ['doc-a'], adoptedDocIds: [], finalAssistantText: 'used doc-a', + recalledDocPaths: { 'doc-a': '/l/doc-a.md' }, recalledDocScopes: {}, + }); + const registry = buildHandlerRegistry(); + const registration = registry.find((r) => r.event === 'stop' && r.handler.name === 'votes-judge'); + if (!registration) throw new Error('votes-judge handler missing'); + await registration.handler.execute( + { session_id: 'sid-judge-owner', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, + 'claude', config, + ); + const call = mockJudgeAdoption.mock.calls.at(-1); + if (!call) throw new Error('judge not called'); + const roots: unknown = call[3]; + return Array.isArray(roots) ? roots.map(String) : []; + } + + it("keeps the learnings checkout's root when it is this project's", async () => { + const { config, checkoutLearnings } = selfProjectWithCheckoutOf('this project'); + expect(await judgeRoots(config)).toContain(checkoutLearnings); + }); + + it('drops it when the checkout belongs to another repository', async () => { + const { config, checkoutLearnings } = selfProjectWithCheckoutOf('another repository'); + const roots = await judgeRoots(config); + expect(roots).not.toContain(checkoutLearnings); + // The queue and the rest stay. + expect(roots).toContain(path.join(root, 'partition', 'pending-learnings')); + }); + + it("drops it when this project no longer registers it (a clone at the same path proves nothing)", async () => { + const { config, checkoutLearnings } = selfProjectWithCheckoutOf('this project, pruned'); + const roots = await judgeRoots(config); + expect(roots).not.toContain(checkoutLearnings); + expect(roots).toContain(path.join(root, 'partition', 'pending-learnings')); + }); + + it("drops it when the checkout's repository was moved or deleted", async () => { + const { config, checkoutLearnings } = selfProjectWithCheckoutOf('a repository that is gone'); + const roots = await judgeRoots(config); + expect(roots).not.toContain(checkoutLearnings); + expect(roots).toContain(path.join(root, 'partition', 'pending-learnings')); + }); + }); + it('votes-judge re-judges a doc not yet in the ledger (no per-session marker blocks it)', async () => { process.env.TEAMAI_UPVOTE_JUDGE = '1'; // doc-a was judged on an earlier Stop but NOT adopted (so it is NOT in the diff --git a/src/__tests__/import-mr-learnings-checkout.test.ts b/src/__tests__/import-mr-learnings-checkout.test.ts new file mode 100644 index 000000000..9b3b43400 --- /dev/null +++ b/src/__tests__/import-mr-learnings-checkout.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { writeInstallConfig } from './helpers/install-config.js'; + +// Real-git test (#808): `import --from-mr` queues its learning and publishes it +// through the learnings checkout, which in self mode lives in the partition and +// does not exist until something creates it. Only config detection and the MR +// extraction (network + AI) are faked; the queue, the checkout and the publish +// are the real code. + +const testRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-import-mr-808-'))); +const businessRoot = path.join(testRoot, 'business'); +const remote = path.join(testRoot, 'remote.git'); +const dataHome = path.join(testRoot, 'partition'); + +const localConfig = { + repo: { localPath: path.join(businessRoot, '.teamai'), kind: 'self' as const, businessRepoRoot: businessRoot, remote }, + username: 'test', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'project' as const, + projectRoot: businessRoot, + dataHome, +}; + +/** The config teamai detects: the main checkout unless a test runs in a linked worktree. */ +let detected = localConfig; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + autoDetectInit: vi.fn(async () => ({ localConfig: detected, teamConfig: { team: 't', repo: remote } })), +})); + +const IMPORTED = 'mr-note-2026-01-01-aaaaaa.md'; + +vi.mock('../import-mr.js', () => ({ + importFromMR: vi.fn(async (opts: { queueLearning?: (filename: string, content: string) => Promise }) => { + const content = '# Learning imported from an MR\n'; + const learningFile = opts.queueLearning ? await opts.queueLearning(IMPORTED, content) : undefined; + // No repo URL: the command skips the wiki and push steps. + return { learning: { title: 'mr-note', content }, repoUrl: '', learningFile }; + }), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + setSilent: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +const { importCmd } = await import('../import.js'); +const { learningsBranch } = await import('../utils/learnings-branch.js'); +const { pendingLearningsDir } = await import('../utils/pending-learnings.js'); +const { importFromMR } = await import('../import-mr.js'); + +/** Files origin has on teamai-learnings. */ +function publishedLearnings(): string[] { + return execFileSync('git', ['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'teamai-learnings'], { encoding: 'utf8' }) + .split('\n') + .filter(Boolean); +} + +/** Branch heads origin has for teamai-learnings: empty when none was published. */ +function publishedLearningsBranch(): string { + return execFileSync('git', ['ls-remote', '--heads', remote, 'teamai-learnings'], { encoding: 'utf8' }).trim(); +} + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { + cwd, + stdio: 'ignore', + env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.co', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.co' }, + }); +} + +describe('import --from-mr in self mode (#808)', () => { + beforeEach(() => { + fs.mkdirSync(path.join(businessRoot, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(businessRoot, '.teamai', '.gitignore'), 'learnings-wt/\n'); + git(['init', '-q', '--bare', remote], testRoot); + git(['init', '-q', '-b', 'main'], businessRoot); + // The learnings checkout commits in-process, with the repo's identity: + // a CI runner has no global one. + git(['config', 'user.email', 't@t.co'], businessRoot); + git(['config', 'user.name', 't'], businessRoot); + git(['add', '-A'], businessRoot); + git(['commit', '-q', '-m', 'init'], businessRoot); + git(['remote', 'add', 'origin', remote], businessRoot); + git(['push', '-q', '-u', 'origin', 'main'], businessRoot); + writeInstallConfig(localConfig); + }); + + afterEach(() => { + detected = localConfig; + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it('publishes the imported learning when the learnings checkout did not exist yet', async () => { + expect(fs.existsSync(learningsBranch.dir(localConfig))).toBe(false); + + await importCmd({ fromMr: 'https://github.com/o/r/pull/1' }); + + // Queued in the partition, not in the checkout, and drained once published. + expect(pendingLearningsDir(localConfig)).toBe(path.join(dataHome, 'pending-learnings')); + expect(publishedLearnings()).toContain(`learnings/${IMPORTED}`); + expect(fs.existsSync(path.join(pendingLearningsDir(localConfig), IMPORTED))).toBe(false); + }); + + it('publishes what an older teamai queued in a linked worktree, so removing the worktree loses nothing', async () => { + const worktree = path.join(testRoot, 'business-wt'); + git(['worktree', 'add', '-q', worktree, '-b', 'wt-808'], businessRoot); + const legacy = 'legacy-wt-2026-01-01-bbbbbb.md'; + const legacyQueue = path.join(worktree, '.teamai', 'pending-learnings'); + fs.mkdirSync(legacyQueue, { recursive: true }); + fs.writeFileSync(path.join(legacyQueue, legacy), '# Queued in the worktree by an older teamai\n'); + detected = { + ...localConfig, + repo: { ...localConfig.repo, localPath: path.join(worktree, '.teamai'), businessRepoRoot: worktree }, + projectRoot: worktree, + }; + + await importCmd({ fromMr: 'https://github.com/o/r/pull/1' }); + + git(['worktree', 'remove', '--force', worktree], businessRoot); + expect(publishedLearnings()).toEqual(expect.arrayContaining([`learnings/${legacy}`, `learnings/${IMPORTED}`])); + expect(fs.readdirSync(pendingLearningsDir(localConfig))).toEqual([]); + }); + + it('leaves the learnings checkout alone when the learning goes to --output', async () => { + await importCmd({ fromMr: 'https://github.com/o/r/pull/1', output: path.join(testRoot, 'drafts') }); + + expect(fs.existsSync(path.join(learningsBranch.dir(localConfig), '.git'))).toBe(false); + }); + + it('publishes no teamai-learnings branch when the extraction fails', async () => { + vi.mocked(importFromMR).mockRejectedValueOnce(new Error('MR fetch failed')); + const exit = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + try { + await expect(importCmd({ fromMr: 'https://github.com/o/r/pull/1' })).rejects.toThrow('process.exit(1)'); + } finally { + exit.mockRestore(); + } + + expect(publishedLearningsBranch()).toBe(''); + }); + + it('publishes no teamai-learnings branch when no learning is accepted', async () => { + vi.mocked(importFromMR).mockResolvedValueOnce({ learning: undefined, repoUrl: '' }); + + await importCmd({ fromMr: 'https://github.com/o/r/pull/1' }); + + expect(publishedLearningsBranch()).toBe(''); + }); +}); diff --git a/src/__tests__/index-migration-trigger.test.ts b/src/__tests__/index-migration-trigger.test.ts new file mode 100644 index 000000000..a10ce585c --- /dev/null +++ b/src/__tests__/index-migration-trigger.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Which commands move a checkout's `.teamai/` into the partition before they +// run (#808): a mode that never writes this project's queue has no reason to, +// and must not stop on it. +vi.mock('../migrate.js', () => ({ + maybeMigrate: vi.fn(async () => undefined), + queueKeptInCheckout: vi.fn(async () => null), +})); +vi.mock('../contribute.js', () => ({ contribute: vi.fn(async () => {}) })); +vi.mock('../import.js', () => ({ importCmd: vi.fn(async () => {}) })); +vi.mock('../init.js', () => ({ init: vi.fn(async () => {}) })); +vi.mock('../roles-cmd.js', () => ({ rolesInit: vi.fn(async () => {}) })); +vi.mock('../utils/logger.js', async (importOriginal) => ({ + ...(await importOriginal()), + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +beforeEach(() => { + vi.stubEnv('TEAMAI_COMMAND_TABLE_ONLY', '1'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +/** Parse `args` as the CLI would; returns whether the pre-command migration ran. */ +async function migrates(args: string[]): Promise { + const { program } = await import('../index.js'); + await program.parseAsync(['node', 'teamai', ...args]); + const { maybeMigrate } = await import('../migrate.js'); + return vi.mocked(maybeMigrate).mock.calls.length > 0; +} + +describe('pre-command migration trigger (#808)', () => { + const mr = 'https://github.com/acme/app/pull/7'; + + it.each([ + ['contribute', ['contribute', '--title', 't', '--file', 'n.md']], + ['contribute --scope project', ['contribute', '--scope', 'project', '--title', 't', '--file', 'n.md']], + ['import --from-mr', ['import', '--from-mr', mr]], + ])('%s migrates first', async (_label, args) => { + expect(await migrates(args)).toBe(true); + }); + + it.each([ + ['contribute --scope user', ['contribute', '--scope', 'user', '--title', 't', '--file', 'n.md']], + ['import --from-mr --output ', ['import', '--from-mr', mr, '--output', 'drafts']], + ])('%s leaves the project alone', async (_label, args) => { + expect(await migrates(args)).toBe(false); + }); +}); + +// Codex 5830259599: an init that goes on while the migration is busy, or while +// it kept the checkout's queue, sets the project up and leaves the queue in a +// worktree that `git worktree remove` deletes. +describe('init and the checkout queue (#808)', () => { + it.each([ + ['init ', ['init', 'acme/team']], + ['init --self', ['init', '--self']], + ])('%s stops with exit 1 before it writes anything', async (_label, args) => { + const { queueKeptInCheckout } = await import('../migrate.js'); + vi.mocked(queueKeptInCheckout).mockResolvedValueOnce('kept in checkout'); + const exit = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`exit ${String(code)}`); + }); + try { + await expect(migrates(args)).rejects.toThrow('exit 1'); + } finally { + exit.mockRestore(); + } + const { init } = await import('../init.js'); + const { log } = await import('../utils/logger.js'); + expect(init).not.toHaveBeenCalled(); + expect(log.error).toHaveBeenCalledWith('kept in checkout'); + }); + + it('init --scope user does not ask', async () => { + expect(await migrates(['init', 'acme/team', '--scope', 'user'])).toBe(true); + const { queueKeptInCheckout } = await import('../migrate.js'); + expect(queueKeptInCheckout).not.toHaveBeenCalled(); + }); + + it('roles init, which sets up no install, does not ask', async () => { + await migrates(['roles', 'init']); + const { queueKeptInCheckout } = await import('../migrate.js'); + expect(queueKeptInCheckout).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/maintenance-paths.test.ts b/src/__tests__/maintenance-paths.test.ts index e033529a8..c9522f671 100644 --- a/src/__tests__/maintenance-paths.test.ts +++ b/src/__tests__/maintenance-paths.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { LocalConfig } from '../types.js'; import { LEARNINGS_WORKTREE_DIRNAME, REPORTS_WORKTREE_DIRNAME } from '../types.js'; -import { resolveMaintenancePaths } from '../maintenance/paths.js'; +import { CheckoutLockedError, CheckoutUnavailableError, resolveMaintenancePaths } from '../maintenance/paths.js'; +import { learningsBranch } from '../utils/learnings-branch.js'; import { refreshReportsWorktree } from '../utils/reports-branch.js'; vi.mock('../utils/reports-branch.js', () => ({ @@ -31,7 +32,8 @@ function makeConfig(kind: 'git' | 'self'): LocalConfig { describe('resolveMaintenancePaths', () => { beforeEach(() => { - vi.mocked(refreshReportsWorktree).mockReset().mockResolvedValue(); + vi.mocked(refreshReportsWorktree).mockReset().mockResolvedValue({ status: 'done' }); + vi.spyOn(learningsBranch, 'refresh').mockReset().mockResolvedValue({ status: 'done' }); }); it('reads self-mode votes from the reports worktree', async () => { @@ -63,4 +65,25 @@ describe('resolveMaintenancePaths', () => { expect(refreshReportsWorktree).toHaveBeenCalledOnce(); expect(refreshReportsWorktree).toHaveBeenCalledWith(config, { pushIfCreated: false }); }); + + it('stops before reading votes or touching learnings while the reports lock is taken', async () => { + const config = makeConfig('self'); + const lockPath = '/workspace/project/.teamai/.reports-lock'; + vi.mocked(refreshReportsWorktree).mockResolvedValue({ status: 'busy', lockPath }); + + const resolving = resolveMaintenancePaths(config); + await expect(resolving).rejects.toBeInstanceOf(CheckoutLockedError); + await expect(resolving).rejects.toThrow(`The reports checkout is locked: another teamai command may be updating it, or its lock at ${lockPath} could not be created.`); + expect(learningsBranch.refresh).not.toHaveBeenCalled(); + }); + + it('stops before reading votes or touching learnings when the reports checkout cannot be set up', async () => { + const config = makeConfig('self'); + vi.mocked(refreshReportsWorktree).mockResolvedValue({ status: 'failed', reason: "fatal: 'teamai-reports' is already used by worktree at '/elsewhere'" }); + + const resolving = resolveMaintenancePaths(config); + await expect(resolving).rejects.toBeInstanceOf(CheckoutUnavailableError); + await expect(resolving).rejects.toThrow("The reports checkout could not be set up: fatal: 'teamai-reports' is already used by worktree at '/elsewhere'. Nothing was changed."); + expect(learningsBranch.refresh).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/members.test.ts b/src/__tests__/members.test.ts index ebcbb678c..faa4df1b1 100644 --- a/src/__tests__/members.test.ts +++ b/src/__tests__/members.test.ts @@ -17,12 +17,10 @@ vi.mock('../utils/git.js', () => ({ })); const reportsMocks = vi.hoisted(() => ({ - ensureReportsWorktree: vi.fn(), - refreshReportsWorktree: vi.fn().mockResolvedValue(undefined), + readableReportsWorktree: vi.fn(), })); vi.mock('../utils/reports-branch.js', () => ({ - ensureReportsWorktree: (...args: unknown[]) => reportsMocks.ensureReportsWorktree(...args), - refreshReportsWorktree: (...args: unknown[]) => reportsMocks.refreshReportsWorktree(...args), + readableReportsWorktree: (...args: unknown[]) => reportsMocks.readableReportsWorktree(...args), })); vi.mock('../utils/logger.js', () => ({ @@ -162,8 +160,7 @@ describe('listMembers', () => { reportsDir = path.join(tmpDir, 'reports-wt'); await fse.ensureDir(path.join(cloneDir, 'members')); await fse.ensureDir(path.join(reportsDir, 'members')); - reportsMocks.ensureReportsWorktree.mockReset().mockResolvedValue(reportsDir); - reportsMocks.refreshReportsWorktree.mockReset().mockResolvedValue(undefined); + reportsMocks.readableReportsWorktree.mockReset().mockResolvedValue(reportsDir); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.mocked(log.info).mockClear(); vi.mocked(log.warn).mockClear(); @@ -181,9 +178,23 @@ describe('listMembers', () => { expect(log.info).toHaveBeenCalledWith('No team members registered'); expect(consoleSpy).not.toHaveBeenCalled(); - // Listing is read-only: a cold start must not publish the reports branch. - expect(reportsMocks.refreshReportsWorktree).toHaveBeenCalledWith(expect.anything(), { pushIfCreated: false }); - expect(reportsMocks.ensureReportsWorktree).toHaveBeenCalledWith(expect.anything(), { pushIfCreated: false }); + // Listing is read-only: it reads through the helper that never publishes the reports branch. + expect(reportsMocks.readableReportsWorktree).toHaveBeenCalledOnce(); + }); + + it('stops with exit 1 on a reports checkout teamai refused, whose warning was printed (#808)', async () => { + mockRequireInit(cloneDir); + const { CheckoutRefusedError } = await import('../utils/branch-worktree.js'); + reportsMocks.readableReportsWorktree.mockRejectedValueOnce( + new CheckoutRefusedError('an old checkout has uncommitted changes', 'an old checkout is in the way'), + ); + try { + await expect(listMembers({})).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = undefined; + } + expect(consoleSpy).not.toHaveBeenCalled(); }); it('lists members registered before the reports switch from the default-branch clone', async () => { @@ -203,9 +214,8 @@ describe('listMembers', () => { expect(allOutput).toContain('stale'); expect(allOutput).toContain('Leftover on clone'); expect(allOutput).toContain('Team members (1)'); - // Listing is read-only: a cold start must not publish the reports branch. - expect(reportsMocks.refreshReportsWorktree).toHaveBeenCalledWith(expect.anything(), { pushIfCreated: false }); - expect(reportsMocks.ensureReportsWorktree).toHaveBeenCalledWith(expect.anything(), { pushIfCreated: false }); + // Listing is read-only: it reads through the helper that never publishes the reports branch. + expect(reportsMocks.readableReportsWorktree).toHaveBeenCalledOnce(); }); it('should display members without role tags', async () => { diff --git a/src/__tests__/migrate.test.ts b/src/__tests__/migrate.test.ts index 98871508f..8fb2c0048 100644 --- a/src/__tests__/migrate.test.ts +++ b/src/__tests__/migrate.test.ts @@ -18,9 +18,46 @@ vi.mock('../utils/logger.js', () => ({ }, })); -import { planMigration, runMigration, maybeMigrate } from '../migrate.js'; +// Runs once after the next listing, to queue a learning while a drain is under way. +const listing = vi.hoisted((): { after: (() => void | Promise) | undefined } => ({ after: undefined })); +vi.mock('../utils/fs.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listFilesRecursive: async (dir: string) => { + const files = await actual.listFilesRecursive(dir); + const after = listing.after; + listing.after = undefined; + await after?.(); + return files; + }, + }; +}); + +import { planMigration, runMigration, maybeMigrate, queueKeptInCheckout } from '../migrate.js'; +import { readConfigFrom } from '../config.js'; import { log } from '../utils/logger.js'; import { projectDataHome } from '../utils/partition.js'; +import { queueLockPath, savePendingLearning, setAsideQueueOnModeSwitch, type QueueWrite } from '../utils/pending-learnings.js'; +import { acquireLock, releaseLock } from '../update.js'; + +/** + * Start a queue write as a contribute already running beside the migration + * would, and give it `ms` to finish before the migration goes on: without the + * queue lock it is done by then; with it, it waits for the migration. + */ +async function startBeside(write: () => Promise, ms = 300): Promise<{ result: Promise }> { + const result = write(); + await Promise.race([result, new Promise((resolve) => setTimeout(resolve, ms))]); + return { result }; +} + +/** The config a contribute loaded from the checkout's `.teamai/` before the migration moved it. */ +async function checkoutConfig() { + const config = await readConfigFrom(legacyDir, repoRoot); + if (!config) throw new Error(`no config in ${legacyDir}`); + return config; +} // ─── Real-git migration tests (issue #374 P1-3) ───────────────────────────── // @@ -395,6 +432,73 @@ describe('runMigration', () => { expect(await fse.pathExists(path.join(partition, 'sentinel'))).toBe(true); }); + it.each([ + ['planned retire-only', 'retire-only'], + ['a partition built after planning', 'full'], + ])("moves a checkout's queue into the built partition before it retires the rest, %s (#808)", async (_label, mode) => { + await seedLegacyLayout(); + const partition = projectDataHome(repoRoot); + if (mode === 'retire-only') await writePartitionConfig(partition); + const plan = await planMigration(repoRoot); + expect(plan?.mode).toBe(mode); + if (mode === 'full') await writePartitionConfig(partition); + await fse.outputFile(path.join(legacyDir, 'pending-learnings', 'wt-note-2026-01-01-aaaaaa.md'), '# wt\n'); + await fse.outputFile(path.join(partition, 'pending-learnings', 'root-note-2026-01-01-bbbbbb.md'), '# root\n'); + + expect(await runMigration(plan!)).toBe('migrated'); + + const queue = path.join(partition, 'pending-learnings'); + expect(await fse.readFile(path.join(queue, 'wt-note-2026-01-01-aaaaaa.md'), 'utf-8')).toBe('# wt\n'); + expect(await fse.readFile(path.join(queue, 'root-note-2026-01-01-bbbbbb.md'), 'utf-8')).toBe('# root\n'); + expect(await fse.pathExists(path.join(`${legacyDir}.bak`, 'pending-learnings'))).toBe(false); + }); + + it('never retires a learning queued with the old config into .teamai.bak (#823 item 11)', async () => { + await seedLegacyLayout(); + const partition = projectDataHome(repoRoot); + await writePartitionConfig(partition); + const config = await checkoutConfig(); + const plan = await planMigration(repoRoot); + if (plan?.mode !== 'retire-only') throw new Error('expected a retire-only plan'); + + // Between the last queue check and the rename, a contribute that loaded the + // checkout's config queues a learning. + let late: { result: Promise } | undefined; + const rename = fse.rename.bind(fse); + const spy = vi.spyOn(fse, 'rename').mockImplementation(async (src: fs.PathLike, dest: fs.PathLike) => { + if (src === legacyDir && !late) late = await startBeside(() => savePendingLearning(config, 'late.md', '# late\n')); + return rename(src, dest); + }); + try { + expect(await runMigration(plan)).toBe('migrated'); + } finally { + spy.mockRestore(); + } + + const result = await late?.result; + expect(await fse.pathExists(path.join(`${legacyDir}.bak`, 'pending-learnings', 'late.md'))).toBe(false); + expect(await fse.pathExists(legacyDir)).toBe(false); + expect(result?.status).toBe('changed'); + }); + + it("keeps the legacy dir while its queue cannot move into the built partition (#808)", async () => { + await seedLegacyLayout(); + const partition = projectDataHome(repoRoot); + await writePartitionConfig(partition); + await fse.outputFile(path.join(legacyDir, 'pending-learnings', 'note-2026-01-01-aaaaaa.md'), '# mine\n'); + await fse.outputFile(path.join(partition, 'pending-learnings', 'note-2026-01-01-aaaaaa.md'), '# theirs\n'); + + const plan = await planMigration(repoRoot); + expect(plan?.mode).toBe('retire-only'); + expect(await runMigration(plan!)).toBe('skipped'); + + expect(await fse.readFile(path.join(legacyDir, 'pending-learnings', 'note-2026-01-01-aaaaaa.md'), 'utf-8')).toBe('# mine\n'); + expect(await fse.pathExists(`${legacyDir}.bak`)).toBe(false); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining(`Kept 1 queued learning(s) in ${path.join(legacyDir, 'pending-learnings')}`), + ); + }); + it('aborts without touching the source when the staged clone is corrupt', async () => { // A team-repo whose .git is present but not a real repo → verify's rev-parse // smoke-check must fail, leaving the source intact and no partition/.bak. @@ -528,6 +632,18 @@ describe('self mode migration (P2)', () => { await fse.writeFile(path.join(legacyDir, 'reports-wt', 'x'), 'wt\n'); } + /** A self config in the partition that detection loads, as a relocated checkout left it. */ + async function writeSelfPartitionConfig(partition: string, username = 'tester'): Promise { + await fse.outputFile( + path.join(partition, 'config.yaml'), + YAML.stringify({ + repo: { localPath: legacyDir, remote: 'git@example.com:t/r.git', kind: 'self', businessRepoRoot: repoRoot }, + username, + scope: 'project', + }), + ); + } + it('relocates A1 machine data to the partition and leaves class-B knowledge in the repo', async () => { await seedSelfLayout(); const plan = await planMigration(repoRoot); @@ -537,10 +653,14 @@ describe('self mode migration (P2)', () => { const partition = projectDataHome(repoRoot); // A1 moved to the partition... - for (const a1 of ['config.yaml', 'state.json', 'env.local', 'env.sh', 'search-index.json']) { + for (const a1 of ['config.yaml', 'state.json', 'env.local', 'env.sh']) { expect(await fse.pathExists(path.join(partition, a1))).toBe(true); expect(await fse.pathExists(path.join(legacyDir, a1))).toBe(false); } + // The old shared index is dropped, not moved: each checkout's index lives + // under workspaces// and is rebuilt (#808). + expect(await fse.pathExists(path.join(partition, 'search-index.json'))).toBe(false); + expect(await fse.pathExists(path.join(legacyDir, 'search-index.json'))).toBe(false); expect(await fse.pathExists(path.join(partition, 'workspaces', 'ws1', 'managed-mcp.json'))).toBe(true); expect(await fse.pathExists(path.join(legacyDir, 'workspaces'))).toBe(false); @@ -574,11 +694,11 @@ describe('self mode migration (P2)', () => { // Simulate a prior partial run: config already in the partition (authoritative), // but a stale copy also lingers in the repo. await fse.ensureDir(partition); - await fse.writeFile(path.join(partition, 'config.yaml'), 'repo:\n kind: self\nAUTHORITATIVE: true\n'); + await writeSelfPartitionConfig(partition, 'authoritative'); await runMigration((await planMigration(repoRoot))!); // The authoritative partition config was NOT overwritten by the repo's copy. - expect(await fse.readFile(path.join(partition, 'config.yaml'), 'utf-8')).toContain('AUTHORITATIVE'); + expect(await fse.readFile(path.join(partition, 'config.yaml'), 'utf-8')).toContain('username: authoritative'); // The stale repo copy was removed. expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(false); // A second plan is null — nothing left to relocate. @@ -599,7 +719,7 @@ describe('self mode migration (P2)', () => { await fse.ensureDir(path.join(legacyDir, 'workspaces', 'wsB')); await fse.writeFile(path.join(legacyDir, 'workspaces', 'wsB', 'managed-mcp.json'), '{"b":2}'); // also give partition an authoritative config so the run reaches the merge branch - await fse.writeFile(path.join(partition, 'config.yaml'), 'repo:\n kind: self\n'); + await writeSelfPartitionConfig(partition); await runMigration((await planMigration(repoRoot))!); @@ -620,7 +740,7 @@ describe('self mode migration (P2)', () => { // partition already has ws1 with authoritative content — merge must keep it. await fse.ensureDir(path.join(partition, 'workspaces', 'ws1')); await fse.writeFile(path.join(partition, 'workspaces', 'ws1', 'managed-mcp.json'), '{"authoritative":true}'); - await fse.writeFile(path.join(partition, 'config.yaml'), 'repo:\n kind: self\n'); + await writeSelfPartitionConfig(partition); await runMigration((await planMigration(repoRoot))!); @@ -655,6 +775,225 @@ describe('self mode migration (P2)', () => { expect(await planMigration(repoRoot)).toBeNull(); }); + /** A self install already relocated to the partition, as every checkout after P2 sees it. */ + async function seedRelocatedSelfInstall(): Promise { + const partition = projectDataHome(repoRoot); + await fse.ensureDir(partition); + await fse.writeFile( + path.join(partition, 'config.yaml'), + YAML.stringify({ + repo: { localPath: legacyDir, remote: 'git@example.com:t/r.git', kind: 'self', businessRepoRoot: repoRoot }, + username: 'tester', + scope: 'project', + }), + ); + await fse.ensureDir(legacyDir); + await fse.writeFile(path.join(legacyDir, 'teamai.yaml'), 'team: t\nmode: self\n'); + return partition; + } + + it("moves a checkout's queued learnings into the shared partition queue (#808)", async () => { + const partition = await seedRelocatedSelfInstall(); + const oldQueue = path.join(legacyDir, 'pending-learnings'); + const queue = path.join(partition, 'pending-learnings'); + await fse.outputFile(path.join(oldQueue, 'root-note-2026-01-01-aaaaaa.md'), '# root\n'); + await fse.outputFile(path.join(oldQueue, 'alpha', 'ns-note-2026-01-01-bbbbbb.md'), '# ns\n'); + // Another checkout already queued into the same namespace. + await fse.outputFile(path.join(queue, 'alpha', 'other-2026-01-01-cccccc.md'), '# other\n'); + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + expect(plan.mode).toBe('self'); + await runMigration(plan); + + expect(await fse.readFile(path.join(queue, 'root-note-2026-01-01-aaaaaa.md'), 'utf-8')).toBe('# root\n'); + expect(await fse.readFile(path.join(queue, 'alpha', 'ns-note-2026-01-01-bbbbbb.md'), 'utf-8')).toBe('# ns\n'); + expect(await fse.readFile(path.join(queue, 'alpha', 'other-2026-01-01-cccccc.md'), 'utf-8')).toBe('# other\n'); + expect(await fse.pathExists(oldQueue)).toBe(false); + expect(await planMigration(repoRoot)).toBeNull(); + }); + + it("keeps every piece of a checkout's machine data while the partition config cannot be read (#808)", async () => { + await seedSelfLayout(); + const oldQueue = path.join(legacyDir, 'pending-learnings'); + await fse.outputFile(path.join(oldQueue, 'kept-2026-01-01-aaaaaa.md'), '# kept\n'); + const legacyConfig = await fse.readFile(path.join(legacyDir, 'config.yaml'), 'utf-8'); + const partition = projectDataHome(repoRoot); + await fse.ensureDir(partition); + await fse.writeFile(path.join(partition, 'config.yaml'), ':::not yaml:::\n'); + + const spy = vi.spyOn(process, 'cwd').mockReturnValue(repoRoot); + try { + await maybeMigrate(); + } finally { + spy.mockRestore(); + } + + expect(await fse.readFile(path.join(legacyDir, 'config.yaml'), 'utf-8')).toBe(legacyConfig); + for (const a1 of ['state.json', 'env.local', 'env.sh', 'search-index.json', 'workspaces']) { + expect(await fse.pathExists(path.join(legacyDir, a1)), a1).toBe(true); + } + expect(await fse.readFile(path.join(oldQueue, 'kept-2026-01-01-aaaaaa.md'), 'utf-8')).toBe('# kept\n'); + expect(await fse.readdir(partition)).toEqual(['config.yaml']); + expect(await fse.readFile(path.join(partition, 'config.yaml'), 'utf-8')).toBe(':::not yaml:::\n'); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining(`Kept the machine data in ${legacyDir}: ${path.join(partition, 'config.yaml')} cannot be read`), + ); + // The command goes on with the checkout's own config. + const { detectProjectConfig } = await import('../config.js'); + expect((await detectProjectConfig(repoRoot))?.repo.kind).toBe('self'); + }); + + it("keeps the checkout's config.yaml while its queue cannot move, so the next run settles it (#808)", async () => { + await seedSelfLayout(); + const oldQueue = path.join(legacyDir, 'pending-learnings'); + await fse.outputFile(path.join(oldQueue, 'kept-2026-01-01-aaaaaa.md'), '# kept\n'); + // Another checkout's init switched the partition to a git install. + const partition = projectDataHome(repoRoot); + await fse.outputFile(path.join(partition, 'config.yaml'), YAML.stringify({ + repo: { localPath: path.join(partition, 'team-repo'), remote: 'git@example.com:team/repo.git', kind: 'git' }, + username: 'tester', + scope: 'project', + })); + const plan = await planMigration(repoRoot); + if (plan?.mode !== 'self') throw new Error('expected a self plan'); + + const lock = await queueLockPath(partition); + expect(await acquireLock(lock)).toBe(true); + try { + expect(await runMigration(plan)).toBe('skipped'); + } finally { + await releaseLock(lock); + } + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(true); + expect(await fse.readFile(path.join(oldQueue, 'kept-2026-01-01-aaaaaa.md'), 'utf-8')).toBe('# kept\n'); + + const retry = await planMigration(repoRoot); + if (retry?.mode !== 'self') throw new Error('expected the retry to take the self path again'); + expect(await runMigration(retry)).toBe('migrated'); + expect(await fse.readFile(path.join(partition, 'pending-learnings.self', 'kept-2026-01-01-aaaaaa.md'), 'utf-8')).toBe('# kept\n'); + expect(await fse.pathExists(oldQueue)).toBe(false); + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(false); + }); + + it("drops every checkout's search index when it moves queued learnings, and nothing else (#808)", async () => { + const partition = await seedRelocatedSelfInstall(); + await fse.outputFile(path.join(legacyDir, 'pending-learnings', 'moved-2026-01-01-aaaaaa.md'), '# moved\n'); + for (const id of ['aaaaaaaaaaaa', 'bbbbbbbbbbbb']) { + await fse.outputJson(path.join(partition, 'workspaces', id, 'search-index.json'), { version: 1 }); + await fse.outputJson(path.join(partition, 'workspaces', id, 'managed-mcp.json'), {}); + } + // A stray file beside the checkout directories is not one of them. + await fse.outputFile(path.join(partition, 'workspaces', '.DS_Store'), ''); + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + await runMigration(plan); + + for (const id of ['aaaaaaaaaaaa', 'bbbbbbbbbbbb']) { + // Recall rebuilds a missing index, now with the moved learning. + expect(await fse.pathExists(path.join(partition, 'workspaces', id, 'search-index.json'))).toBe(false); + expect(await fse.pathExists(path.join(partition, 'workspaces', id, 'managed-mcp.json'))).toBe(true); + } + }); + + it("drops every checkout's search index when an earlier run already placed the learning (#808)", async () => { + const partition = await seedRelocatedSelfInstall(); + // A run that crashed after renaming it into the partition queue, before it + // dropped the indexes, left the same file in both queues. + for (const queue of [legacyDir, partition]) { + await fse.outputFile(path.join(queue, 'pending-learnings', 'placed-2026-01-01-aaaaaa.md'), '# placed\n'); + } + await fse.outputJson(path.join(partition, 'workspaces', 'aaaaaaaaaaaa', 'search-index.json'), { version: 1 }); + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + await runMigration(plan); + + expect(await fse.pathExists(path.join(legacyDir, 'pending-learnings'))).toBe(false); + expect(await fse.pathExists(path.join(partition, 'workspaces', 'aaaaaaaaaaaa', 'search-index.json'))).toBe(false); + }); + + it("keeps a checkout's queue out of the live queue of a kind init switches to while it drains (#823 item 11)", async () => { + const partition = await seedRelocatedSelfInstall(); + const moved = 'drained-2026-01-01-aaaaaa.md'; + await fse.outputFile(path.join(legacyDir, 'pending-learnings', moved), '# drained\n'); + const self = await readConfigFrom(partition, repoRoot); + if (!self) throw new Error('expected the partition config'); + const git = { ...self, repo: { localPath: path.join(partition, 'team-repo'), remote: 'git@example.com:team/repo.git', kind: 'git' as const } }; + + // Another checkout's init switches the project to git mode while this + // migration drains the checkout's self queue into the partition. + let switched: Promise | undefined; + listing.after = async () => { + switched = setAsideQueueOnModeSwitch(self, git, () => + fse.writeFile(path.join(partition, 'config.yaml'), YAML.stringify({ repo: git.repo, username: 'tester', scope: 'project' }))); + await Promise.race([switched, new Promise((resolve) => setTimeout(resolve, 300))]); + }; + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + await runMigration(plan); + await switched; + + expect(await fse.pathExists(path.join(partition, 'pending-learnings', moved))).toBe(false); + expect(await fse.readFile(path.join(partition, 'pending-learnings.self', moved), 'utf-8')).toBe('# drained\n'); + }); + + it('skips a queued learning another drain moved while this one ran (#808)', async () => { + const partition = await seedRelocatedSelfInstall(); + const oldQueue = path.join(legacyDir, 'pending-learnings'); + const moved = 'raced-2026-01-01-aaaaaa.md'; + await fse.outputFile(path.join(oldQueue, moved), '# raced\n'); + // A contribute beside this pull moves it first. + listing.after = () => { + fs.mkdirSync(path.join(partition, 'pending-learnings'), { recursive: true }); + fs.renameSync(path.join(oldQueue, moved), path.join(partition, 'pending-learnings', moved)); + }; + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + await expect(runMigration(plan)).resolves.toBe('migrated'); + + expect(await fse.readFile(path.join(partition, 'pending-learnings', moved), 'utf-8')).toBe('# raced\n'); + }); + + it('keeps a learning queued in the old queue while it was being drained (#808)', async () => { + const partition = await seedRelocatedSelfInstall(); + const oldQueue = path.join(legacyDir, 'pending-learnings'); + await fse.outputFile(path.join(oldQueue, 'first-2026-01-01-aaaaaa.md'), '# first\n'); + const late = path.join(oldQueue, 'late-2026-01-01-bbbbbb.md'); + listing.after = () => fs.writeFileSync(late, '# late\n'); + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + await runMigration(plan); + + expect(await fse.pathExists(path.join(partition, 'pending-learnings', 'first-2026-01-01-aaaaaa.md'))).toBe(true); + expect(await fse.readFile(late, 'utf-8')).toBe('# late\n'); + }); + + it('keeps a queued learning whose name the partition queue holds with other content (#808)', async () => { + const partition = await seedRelocatedSelfInstall(); + const oldQueue = path.join(legacyDir, 'pending-learnings'); + const queue = path.join(partition, 'pending-learnings'); + await fse.outputFile(path.join(oldQueue, 'same-2026-01-01-aaaaaa.md'), '# same\n'); + await fse.outputFile(path.join(queue, 'same-2026-01-01-aaaaaa.md'), '# same\n'); + await fse.outputFile(path.join(oldQueue, 'edited-2026-01-01-bbbbbb.md'), '# edited here\n'); + await fse.outputFile(path.join(queue, 'edited-2026-01-01-bbbbbb.md'), '# edited there\n'); + vi.mocked(log.warn).mockClear(); + + const plan = await planMigration(repoRoot); + if (!plan) throw new Error('expected a self migration plan'); + await runMigration(plan); + + // The copy that already moved is dropped; neither side of the conflict is lost. + expect(await fse.pathExists(path.join(oldQueue, 'same-2026-01-01-aaaaaa.md'))).toBe(false); + expect(await fse.readFile(path.join(oldQueue, 'edited-2026-01-01-bbbbbb.md'), 'utf-8')).toBe('# edited here\n'); + expect(await fse.readFile(path.join(queue, 'edited-2026-01-01-bbbbbb.md'), 'utf-8')).toBe('# edited there\n'); + expect(vi.mocked(log.warn).mock.calls.flat().join('\n')).toContain('edited-2026-01-01-bbbbbb.md'); + }); + it('dry-run relocates nothing', async () => { await seedSelfLayout(); const result = await runMigration((await planMigration(repoRoot))!, { dryRun: true }); @@ -688,6 +1027,134 @@ describe('self mode migration (P2)', () => { }); }); +describe('superseded install (#808)', () => { + /** A git install beside the self knowledge another checkout's `init --self` committed. */ + async function seedSupersededInstall(): Promise { + await writeLegacyConfig(); + await fse.writeJson(path.join(legacyDir, 'state.json'), { lastSync: 'x' }); + await fse.writeFile(path.join(legacyDir, 'env'), 'TEAM_TOKEN=s3cret\n'); + await fse.outputFile(path.join(legacyDir, 'team-repo', 'README'), 'team\n'); + await fse.writeFile(path.join(legacyDir, 'teamai.yaml'), 'team: t\nmode: self\n'); + await fse.outputFile(path.join(legacyDir, 'skills', 'team-skill.md'), '# team\n'); + const partition = projectDataHome(repoRoot); + await fse.ensureDir(partition); + await fse.writeFile( + path.join(partition, 'config.yaml'), + YAML.stringify({ + repo: { localPath: legacyDir, remote: 'git@example.com:t/r.git', kind: 'self', businessRepoRoot: repoRoot }, + username: 'tester', + scope: 'project', + }), + ); + } + + it('keeps config.yaml in place when a move fails partway, so the next run finishes the job', async () => { + await seedSupersededInstall(); + const plan = await planMigration(repoRoot); + if (plan?.mode !== 'superseded') throw new Error('expected a superseded plan'); + + // As Windows refuses to move a directory another process has open. + const move = fse.move.bind(fse); + const spy = vi.spyOn(fse, 'move').mockImplementation(async (src: string, dest: string) => { + if (path.basename(src) === 'team-repo') throw new Error('EBUSY: resource busy or locked'); + return move(src, dest); + }); + try { + await expect(runMigration(plan)).rejects.toThrow(/EBUSY/); + } finally { + spy.mockRestore(); + } + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(true); + + const retry = await planMigration(repoRoot); + if (retry?.mode !== 'superseded') throw new Error('expected the retry to take the superseded path again'); + expect(await runMigration(retry)).toBe('migrated'); + + for (const name of ['config.yaml', 'state.json', 'env', 'team-repo']) { + expect(await fse.pathExists(path.join(legacyDir, name)), name).toBe(false); + } + const backups = (await fse.readdir(repoRoot)).filter((n) => n.startsWith('.teamai.bak')); + const inBackups = []; + for (const b of backups) inBackups.push(...(await fse.readdir(path.join(repoRoot, b)))); + expect(inBackups).toEqual(expect.arrayContaining(['config.yaml', 'state.json', 'env', 'team-repo'])); + expect(await fse.readFile(path.join(legacyDir, 'skills', 'team-skill.md'), 'utf-8')).toBe('# team\n'); + expect(await planMigration(repoRoot)).toBeNull(); + }); + + it('keeps config.yaml beside a learning queued after its queue was set aside, so the next run sets that one aside too', async () => { + await seedSupersededInstall(); + const queue = path.join(legacyDir, 'pending-learnings'); + await fse.outputFile(path.join(queue, 'early.md'), '# early\n'); + const plan = await planMigration(repoRoot); + if (plan?.mode !== 'superseded') throw new Error('expected a superseded plan'); + + // As an older `contribute` running beside the migration queues one. + const move = fse.move.bind(fse); + const spy = vi.spyOn(fse, 'move').mockImplementation(async (src: string, dest: string) => { + await move(src, dest); + if (src === queue) await fse.outputFile(path.join(queue, 'late.md'), '# late\n'); + }); + try { + expect(await runMigration(plan)).toBe('skipped'); + } finally { + spy.mockRestore(); + } + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(true); + expect(await fse.readFile(path.join(queue, 'late.md'), 'utf-8')).toBe('# late\n'); + + const retry = await planMigration(repoRoot); + if (retry?.mode !== 'superseded') throw new Error('expected the retry to take the superseded path again'); + expect(await runMigration(retry)).toBe('migrated'); + + const partition = projectDataHome(repoRoot); + const asideDirs = (await fse.readdir(partition)).filter((n) => n.startsWith('pending-learnings.git')); + const aside = []; + for (const d of asideDirs) aside.push(...(await fse.readdir(path.join(partition, d)))); + expect(aside.sort()).toEqual(['early.md', 'late.md']); + expect(await fse.pathExists(path.join(partition, 'pending-learnings', 'late.md'))).toBe(false); + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(false); + expect(await planMigration(repoRoot)).toBeNull(); + }); + + it('retires an old queue that holds no learning, only another file', async () => { + await seedSupersededInstall(); + await fse.outputFile(path.join(legacyDir, 'pending-learnings', 'notes.txt'), 'scratch\n'); + const plan = await planMigration(repoRoot); + if (plan?.mode !== 'superseded') throw new Error('expected a superseded plan'); + + expect(await runMigration(plan)).toBe('migrated'); + + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(false); + }); + + it('never leaves a learning queued with the old config beside the self knowledge without its config.yaml (#823 item 11)', async () => { + await seedSupersededInstall(); + const config = await checkoutConfig(); + const plan = await planMigration(repoRoot); + if (plan?.mode !== 'superseded') throw new Error('expected a superseded plan'); + + // Between the last queue check and the config.yaml move. + let late: { result: Promise } | undefined; + const move = fse.move.bind(fse); + const spy = vi.spyOn(fse, 'move').mockImplementation(async (src: string, dest: string) => { + if (src === path.join(legacyDir, 'config.yaml') && !late) { + late = await startBeside(() => savePendingLearning(config, 'late.md', '# late\n')); + } + return move(src, dest); + }); + try { + expect(await runMigration(plan)).toBe('migrated'); + } finally { + spy.mockRestore(); + } + + const result = await late?.result; + expect(await fse.pathExists(path.join(legacyDir, 'pending-learnings', 'late.md'))).toBe(false); + expect(await fse.pathExists(path.join(legacyDir, 'config.yaml'))).toBe(false); + expect(result?.status).toBe('changed'); + }); +}); + describe('maybeMigrate', () => { /** Run the pre-command migration from inside the business repo. */ async function migrateFromRepo(): Promise { @@ -773,3 +1240,28 @@ describe('maybeMigrate', () => { } }); }); + +describe('queueKeptInCheckout', () => { + async function keptFrom(cwd: string): Promise { + const spy = vi.spyOn(process, 'cwd').mockReturnValue(cwd); + try { + return await queueKeptInCheckout(undefined); + } finally { + spy.mockRestore(); + } + } + + it("stops on a queue an older teamai left in a checkout's .teamai/ (#808)", async () => { + await fse.outputFile(path.join(legacyDir, 'pending-learnings', 'old-2026-01-01-aaaaaa.md'), '# Old\n'); + + expect(await keptFrom(repoRoot)).toContain(path.join(legacyDir, 'pending-learnings')); + }); + + it("leaves the user scope's queue alone when the home itself is a git repo", async () => { + // A dotfiles repo at ~: its `.teamai/` is the user scope's data home. + vi.stubEnv('HOME', repoRoot); + await fse.outputFile(path.join(legacyDir, 'pending-learnings', 'user-2026-01-01-aaaaaa.md'), '# User\n'); + + expect(await keptFrom(repoRoot)).toBeNull(); + }); +}); diff --git a/src/__tests__/pending-learnings-mode-switch.test.ts b/src/__tests__/pending-learnings-mode-switch.test.ts new file mode 100644 index 000000000..fc61e6c58 --- /dev/null +++ b/src/__tests__/pending-learnings-mode-switch.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import YAML from 'yaml'; + +// #808: git and self mode share the partition's queue, so a re-init that +// switches the install's kind must not hand the old install's learnings to the +// new one's repository. + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +const { listPendingForInstall, queueLockPath, savePendingLearning, setAsideQueueOnModeSwitch } = await import('../utils/pending-learnings.js'); +const { acquireLock, releaseLock } = await import('../update.js'); +const { log } = await import('../utils/logger.js'); + +const noSave = async (): Promise => {}; +function switched(result: Awaited>): { aside: string | null } { + if (result.status !== 'switched') throw new Error(`queue lock busy: ${result.lockPath}`); + return result; +} + +describe('setAsideQueueOnModeSwitch (#808)', () => { + let partition: string; + let project: string; + + beforeEach(() => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-mode-switch-'))); + partition = path.join(root, 'partition'); + project = path.join(root, 'project'); + fs.mkdirSync(partition, { recursive: true }); + fs.mkdirSync(path.join(root, 'home')); + vi.stubEnv('HOME', path.join(root, 'home')); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(path.dirname(partition), { recursive: true, force: true }); + }); + + const selfConfig = () => ({ + repo: { localPath: path.join(project, '.teamai'), remote: 'https://git.example.com/me/project.git', kind: 'self' as const, businessRepoRoot: project }, + username: 'u', + scope: 'project' as const, + projectRoot: project, + dataHome: partition, + additionalRoles: [], + }); + const gitConfig = () => ({ + repo: { localPath: path.join(partition, 'team-repo'), remote: 'https://git.example.com/team/repo.git', kind: 'git' as const }, + username: 'u', + scope: 'project' as const, + projectRoot: project, + additionalRoles: [], + }); + const queue = () => path.join(partition, 'pending-learnings'); + /** The git install as detection loads it from the partition, and its config file there. */ + const detectedGit = () => ({ ...gitConfig(), dataHome: partition }); + const writeConfig = (config: { repo: object; username: string; scope: string }) => + fs.writeFileSync(path.join(partition, 'config.yaml'), YAML.stringify({ repo: config.repo, username: config.username, scope: config.scope })); + + it('keeps a learning queued with the old config out of the new install\'s queue (#823 item 11)', async () => { + writeConfig(gitConfig()); + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md'), '# mine\n'); + + // A contribute that loaded the git config queues while init saves the self one. + let late: { result: ReturnType } | undefined; + switched(await setAsideQueueOnModeSwitch(detectedGit(), selfConfig(), async () => { + const result = savePendingLearning(detectedGit(), 'late-2026-01-01-bbbbbb.md', '# late\n'); + await Promise.race([result, new Promise((resolve) => setTimeout(resolve, 300))]); + late = { result }; + writeConfig(selfConfig()); + })); + + const result = await late?.result; + expect(fs.existsSync(path.join(queue(), 'late-2026-01-01-bbbbbb.md'))).toBe(false); + expect(fs.existsSync(path.join(partition, 'pending-learnings.git', 'mine-2026-01-01-aaaaaa.md'))).toBe(true); + expect(result?.status).toBe('changed'); + }); + + it('saves nothing while another command holds the queue lock (#823 item 11)', async () => { + writeConfig(gitConfig()); + const lock = await queueLockPath(partition); + expect(await acquireLock(lock)).toBe(true); + let result; + try { + result = await savePendingLearning(detectedGit(), 'held-2026-01-01-cccccc.md', '# held\n'); + } finally { + await releaseLock(lock); + } + expect(fs.existsSync(path.join(queue(), 'held-2026-01-01-cccccc.md'))).toBe(false); + expect(result).toEqual({ status: 'busy', lockPath: lock }); + }); + + it('moves a self install\'s queue aside when init switches it to git mode', async () => { + fs.mkdirSync(path.join(queue(), 'ns'), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'ns', 'mine-2026-01-01-aaaaaa.md'), '# mine\n'); + + const { aside } = switched(await setAsideQueueOnModeSwitch(selfConfig(), gitConfig(), noSave)); + + expect(aside).toBe(path.join(partition, 'pending-learnings.self')); + expect(fs.readFileSync(path.join(partition, 'pending-learnings.self', 'ns', 'mine-2026-01-01-aaaaaa.md'), 'utf8')).toBe('# mine\n'); + expect(fs.existsSync(queue())).toBe(false); + expect(vi.mocked(log.warn).mock.calls.flat().join('\n')).toContain('Set aside 1 queued learning(s) from the previous self install'); + }); + + it('never overwrites an earlier set-aside queue', async () => { + fs.mkdirSync(path.join(partition, 'pending-learnings.self'), { recursive: true }); + fs.writeFileSync(path.join(partition, 'pending-learnings.self', 'older-2026-01-01-bbbbbb.md'), '# older\n'); + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'newer-2026-01-01-cccccc.md'), '# newer\n'); + + const { aside } = switched(await setAsideQueueOnModeSwitch(selfConfig(), gitConfig(), noSave)); + + expect(aside).toBe(path.join(partition, 'pending-learnings.self.1')); + expect(fs.existsSync(path.join(partition, 'pending-learnings.self', 'older-2026-01-01-bbbbbb.md'))).toBe(true); + }); + + it('leaves the queue alone when the kind does not change, or nothing is queued', async () => { + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md'), '# mine\n'); + expect(switched(await setAsideQueueOnModeSwitch(gitConfig(), gitConfig(), noSave)).aside).toBeNull(); + expect(switched(await setAsideQueueOnModeSwitch(null, gitConfig(), noSave)).aside).toBeNull(); + fs.rmSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md')); + expect(switched(await setAsideQueueOnModeSwitch(selfConfig(), gitConfig(), noSave)).aside).toBeNull(); + expect(fs.existsSync(queue())).toBe(true); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('moves the queue aside under an unknown owner when the previous config exists but cannot be read', async () => { + fs.writeFileSync(path.join(partition, 'config.yaml'), 'repo: [not a config\n'); + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md'), '# mine\n'); + + const { aside } = switched(await setAsideQueueOnModeSwitch(null, detectedGit(), noSave)); + + expect(aside).toBe(path.join(partition, 'pending-learnings.unknown')); + expect(fs.readFileSync(path.join(partition, 'pending-learnings.unknown', 'mine-2026-01-01-aaaaaa.md'), 'utf8')).toBe('# mine\n'); + expect(fs.existsSync(queue())).toBe(false); + expect(vi.mocked(log.warn).mock.calls.flat().join('\n')).toContain(path.join(partition, 'config.yaml')); + }); + + // #823 item 13: a queue belongs to its kind and its team repository. + const otherTeam = () => ({ ...gitConfig(), repo: { ...gitConfig().repo, remote: 'https://git.example.com/other/team.git' } }); + const sameTeamOtherForm = () => ({ ...gitConfig(), repo: { ...gitConfig().repo, remote: 'git@git.example.com:Team/repo/' } }); + + it("moves a git install's queue aside when init points it at another team repo (#823 item 13)", async () => { + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md'), '# mine\n'); + + const { aside } = switched(await setAsideQueueOnModeSwitch(gitConfig(), otherTeam(), noSave)); + + expect(aside).toBe(path.join(partition, 'pending-learnings.git-git.example.com-team-repo')); + expect(fs.readFileSync(path.join(aside ?? '', 'mine-2026-01-01-aaaaaa.md'), 'utf8')).toBe('# mine\n'); + expect(fs.existsSync(queue())).toBe(false); + const warned = vi.mocked(log.warn).mock.calls.flat().join('\n'); + expect(warned).toContain('Set aside 1 queued learning(s) from the previous git install'); + expect(warned).toContain('https://git.example.com/team/repo.git'); + }); + + it('leaves the queue alone when init names the same team repo in another form (#823 item 13)', async () => { + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md'), '# mine\n'); + + expect(switched(await setAsideQueueOnModeSwitch(gitConfig(), sameTeamOtherForm(), noSave)).aside).toBeNull(); + + expect(fs.existsSync(path.join(queue(), 'mine-2026-01-01-aaaaaa.md'))).toBe(true); + expect(log.warn).not.toHaveBeenCalled(); + // A writer that loaded one form finds the other on disk and still saves. + writeConfig(sameTeamOtherForm()); + expect((await savePendingLearning(detectedGit(), 'form-2026-01-01-dddddd.md', '# form\n')).status).toBe('saved'); + }); + + it('saves and lists nothing for an install init pointed at another team repo meanwhile (#823 item 13)', async () => { + fs.mkdirSync(queue(), { recursive: true }); + fs.writeFileSync(path.join(queue(), 'theirs-2026-01-01-eeeeee.md'), '# theirs\n'); + writeConfig(otherTeam()); + + const write = await savePendingLearning(detectedGit(), 'late-2026-01-01-ffffff.md', '# late\n'); + const listed = await listPendingForInstall(detectedGit()); + + expect(fs.existsSync(path.join(queue(), 'late-2026-01-01-ffffff.md'))).toBe(false); + expect(write).toMatchObject({ status: 'changed', configPath: path.join(partition, 'config.yaml') }); + expect(write.status === 'changed' ? write.cause : '').toBe( + 'now names https://git.example.com/other/team.git, not https://git.example.com/team/repo.git', + ); + expect(listed.status).toBe('changed'); + }); +}); diff --git a/src/__tests__/pending-learnings.test.ts b/src/__tests__/pending-learnings.test.ts index f9e34b6bd..a7d4b0bc8 100644 --- a/src/__tests__/pending-learnings.test.ts +++ b/src/__tests__/pending-learnings.test.ts @@ -18,9 +18,11 @@ import { listPendingLearnings, readPendingLearning, dropPendingLearning, + listQueuesIn, } from '../utils/pending-learnings.js'; import { publishQueuedLearnings } from '../utils/learnings-publish.js'; import type { LocalConfig } from '../types.js'; +import { writeInstallConfig } from './helpers/install-config.js'; function cloneConfig(localPath: string): LocalConfig { return { @@ -61,13 +63,16 @@ describe('the learnings queue', () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-pending-test-')); + vi.stubEnv('HOME', tmpDir); const repoPath = path.join(tmpDir, 'team-repo'); fs.mkdirSync(repoPath); config = cloneConfig(repoPath); + writeInstallConfig(config); pendingDir = pendingLearningsDir(config); }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -126,13 +131,16 @@ describe('publishQueuedLearnings, from an independent clone', () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-publish-test-')); + vi.stubEnv('HOME', tmpDir); repoPath = path.join(tmpDir, 'team-repo'); fs.mkdirSync(repoPath); config = cloneConfig(repoPath); + writeInstallConfig(config); pendingDir = pendingLearningsDir(config); }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -173,6 +181,7 @@ describe('publishQueuedLearnings, from an independent clone', () => { path.join(tmpDir, 'home', '.teamai', '.sync-lock'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'someone-else' }), ); + writeInstallConfig(config); try { await savePendingLearning(config, 'held.md', '# held'); @@ -189,3 +198,33 @@ describe('publishQueuedLearnings, from an independent clone', () => { }); }); + +describe('listQueuesIn', () => { + let home: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-queues-')); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + function queue(rel: string, count: number): void { + fs.mkdirSync(path.join(home, rel), { recursive: true }); + for (let i = 0; i < count; i++) fs.writeFileSync(path.join(home, rel, `l${i}.md`), '# L\n'); + } + + it("lists the project partitions' queues too, which deleting the user data home takes with it (#808)", async () => { + queue('pending-learnings', 1); + queue(path.join('projects', 'app-1a2b', 'pending-learnings'), 2); + queue(path.join('projects', 'app-1a2b', 'pending-learnings.git'), 1); + queue(path.join('projects', 'empty-3c4d', 'pending-learnings'), 0); + + expect(await listQueuesIn(home)).toEqual([ + { dir: path.join(home, 'pending-learnings'), count: 1 }, + { dir: path.join(home, 'projects', 'app-1a2b', 'pending-learnings'), count: 2 }, + { dir: path.join(home, 'projects', 'app-1a2b', 'pending-learnings.git'), count: 1 }, + ]); + }); +}); diff --git a/src/__tests__/projects-members.test.ts b/src/__tests__/projects-members.test.ts index 2c4a608a7..775f0b32f 100644 --- a/src/__tests__/projects-members.test.ts +++ b/src/__tests__/projects-members.test.ts @@ -14,12 +14,10 @@ vi.mock('../utils/git.js', () => ({ })); const reportsMocks = vi.hoisted(() => ({ - ensureReportsWorktree: vi.fn(), - refreshReportsWorktree: vi.fn().mockResolvedValue(undefined), + readableReportsWorktree: vi.fn(), })); vi.mock('../utils/reports-branch.js', () => ({ - ensureReportsWorktree: (...args: unknown[]) => reportsMocks.ensureReportsWorktree(...args), - refreshReportsWorktree: (...args: unknown[]) => reportsMocks.refreshReportsWorktree(...args), + readableReportsWorktree: (...args: unknown[]) => reportsMocks.readableReportsWorktree(...args), })); vi.mock('../utils/logger.js', () => ({ @@ -54,8 +52,7 @@ describe('projectsMembers: inherited member root (#735)', () => { path.join(cloneDir, 'manifest', 'projects.yaml'), YAML.stringify({ version: 1, projects: [{ id: 'checkout', resources: {} }] }), ); - reportsMocks.ensureReportsWorktree.mockReset().mockResolvedValue(reportsDir); - reportsMocks.refreshReportsWorktree.mockReset().mockResolvedValue(undefined); + reportsMocks.readableReportsWorktree.mockReset().mockResolvedValue(reportsDir); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.mocked(autoDetectInit).mockResolvedValue({ localConfig: { @@ -82,6 +79,20 @@ describe('projectsMembers: inherited member root (#735)', () => { await fse.remove(tmpDir); }); + it('stops with exit 1 on a reports checkout teamai refused, whose warning was printed (#808)', async () => { + const { CheckoutRefusedError } = await import('../utils/branch-worktree.js'); + reportsMocks.readableReportsWorktree.mockRejectedValueOnce( + new CheckoutRefusedError('an old checkout has uncommitted changes', 'an old checkout is in the way'), + ); + try { + await expect(projectsMembers('checkout', {})).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = undefined; + } + expect(consoleSpy).not.toHaveBeenCalled(); + }); + it('finds project members registered before the reports switch', async () => { await fse.writeFile( path.join(cloneDir, 'members', 'carol.yaml'), diff --git a/src/__tests__/pull-learnings-deletion.test.ts b/src/__tests__/pull-learnings-deletion.test.ts index e6a0653a6..8580eb9be 100644 --- a/src/__tests__/pull-learnings-deletion.test.ts +++ b/src/__tests__/pull-learnings-deletion.test.ts @@ -22,6 +22,8 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../utils/git.js', () => ({ pullRepo: vi.fn().mockResolvedValue('already up to date'), getHeadRev: vi.fn().mockResolvedValue('abc1234'), + // No learnings checkout exists, so the index's ownership probe passes (#808). + isGitRepo: vi.fn().mockResolvedValue(false), })); vi.mock('../utils/logger.js', () => ({ diff --git a/src/__tests__/pull-post-checks.test.ts b/src/__tests__/pull-post-checks.test.ts index fdebfcb15..d9c894030 100644 --- a/src/__tests__/pull-post-checks.test.ts +++ b/src/__tests__/pull-post-checks.test.ts @@ -13,9 +13,11 @@ vi.mock('../config.js', async (importOriginal) => ({ saveStateForScope: vi.fn(), })); -vi.mock('../utils/git.js', () => ({ +vi.mock('../utils/git.js', async (importOriginal) => ({ getHeadRev: vi.fn().mockResolvedValue('abc1234'), pullRepo: vi.fn().mockResolvedValue('already up to date'), + // The queue compares its install's team repo with the config on disk. + remotesMatch: (await importOriginal()).remotesMatch, })); vi.mock('../utils/logger.js', () => ({ @@ -67,6 +69,7 @@ import { buildChecks, resolveDoctorContext, type Check, type DoctorContext } fro import { log } from '../utils/logger.js'; import { pull } from '../pull.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; +import { writeInstallConfig } from './helpers/install-config.js'; /** Every line pull printed, in order, as one string. */ function printedOutput(): string { @@ -119,6 +122,7 @@ describe('checks at the end of an interactive pull', () => { toolPaths: { claude: { skills: '.claude/skills', rules: '.claude/rules' } }, }; + writeInstallConfig(localConfig); vi.mocked(detectProjectConfig).mockResolvedValue(null); vi.mocked(loadLocalConfigForScope).mockResolvedValue(localConfig); vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); diff --git a/src/__tests__/reports-branch-readonly.test.ts b/src/__tests__/reports-branch-readonly.test.ts index 7f08d9190..6ce850180 100644 --- a/src/__tests__/reports-branch-readonly.test.ts +++ b/src/__tests__/reports-branch-readonly.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ listRemote: vi.fn(), raw: vi.fn(), branchLocal: vi.fn(), + revparse: vi.fn(), }, worktreeGit: { raw: vi.fn(), @@ -43,16 +44,25 @@ vi.mock('fs-extra', () => ({ default: { readdir: vi.fn().mockResolvedValue(['.git']), remove: vi.fn(), + realpath: vi.fn(async (p: string) => p), }, })); +/** The existing reports-wt is a checkout of the business repo: both sides name its git dir. */ +function mockCheckoutOfBusinessRepo(): void { + const answer = async (args: string[]) => (args.includes('--git-common-dir') ? '/workspace/project/.git' : 'true'); + mocks.worktreeGit.revparse.mockImplementation(answer); + mocks.repoGit.revparse.mockImplementation(answer); +} + vi.mock('../update.js', () => ({ acquireLock: vi.fn(), releaseLock: vi.fn(), })); import { acquireLock, releaseLock } from '../update.js'; -import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js'; +import { commitAndPushReports, ensureReportsWorktree, readableReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js'; +import { ForeignCheckoutError } from '../utils/branch-worktree.js'; const config: LocalConfig = { repo: { @@ -139,7 +149,7 @@ describe('commitAndPushReports', () => { mocks.worktreeGit.add.mockResolvedValue(undefined); mocks.worktreeGit.commit.mockResolvedValue(undefined); mocks.worktreeGit.push.mockResolvedValue(undefined); - mocks.worktreeGit.revparse.mockResolvedValue('true'); + mockCheckoutOfBusinessRepo(); // `rev-list --count origin/..HEAD`: the publish confirms the ref // moved before reporting success. '0' is "nothing left to deliver". mocks.worktreeGit.raw.mockResolvedValue('0'); @@ -177,7 +187,7 @@ describe('refreshReportsWorktree', () => { beforeEach(() => { vi.clearAllMocks(); mocks.isGitRepo.mockResolvedValue(true); - mocks.worktreeGit.revparse.mockResolvedValue('true'); + mockCheckoutOfBusinessRepo(); mocks.worktreeGit.fetch.mockResolvedValue(undefined); }); @@ -191,6 +201,19 @@ describe('refreshReportsWorktree', () => { expect(releaseLock).not.toHaveBeenCalled(); }); + it('leaves a missing checkout to the write that holds the lock', async () => { + // Every checkout of a self-mode repo shares this path (#808): the holder may + // be creating it right now. + vi.mocked(acquireLock).mockResolvedValue(false); + mocks.isGitRepo.mockResolvedValue(false); + + await refreshReportsWorktree(config, { pushIfCreated: false }); + + expect(mocks.isGitRepo).not.toHaveBeenCalled(); + expect(mocks.repoGit.raw).not.toHaveBeenCalled(); + expect(releaseLock).not.toHaveBeenCalled(); + }); + it('keeps the local copy when fetch fails (offline)', async () => { vi.mocked(acquireLock).mockResolvedValue(true); mocks.worktreeGit.fetch.mockRejectedValue(new Error('offline')); @@ -202,6 +225,53 @@ describe('refreshReportsWorktree', () => { }); }); +describe('readableReportsWorktree', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCheckoutOfBusinessRepo(); + mocks.worktreeGit.fetch.mockResolvedValue(undefined); + }); + + it('returns the local copy without ensuring it while a write holds the lock', async () => { + // members, projects members, digest and pull read through here (#808): the + // holder may be creating the checkout, so a reader must not add or remove it. + vi.mocked(acquireLock).mockResolvedValue(false); + mocks.isGitRepo.mockResolvedValue(false); + + await expect(readableReportsWorktree(config)).resolves.toBe(WT); + + expect(mocks.repoGit.raw).not.toHaveBeenCalled(); + expect(mocks.repoGit.listRemote).not.toHaveBeenCalled(); + }); + + it("refuses another repository's checkout without ensuring it while a write holds the lock", async () => { + // After a git<->self switch the checkout at the shared path may be the + // other install's: reading it would show that repository's roster and votes. + vi.mocked(acquireLock).mockResolvedValue(false); + mocks.isGitRepo.mockResolvedValue(true); + mocks.worktreeGit.revparse.mockImplementation(async (args: string[]) => ( + args.includes('--git-common-dir') ? '/workspace/other-team/.git' : 'true' + )); + + await expect(readableReportsWorktree(config)).rejects.toBeInstanceOf(ForeignCheckoutError); + + expect(mocks.repoGit.raw).not.toHaveBeenCalled(); + expect(mocks.repoGit.listRemote).not.toHaveBeenCalled(); + expect(mocks.worktreeGit.raw).not.toHaveBeenCalled(); + }); + + it('refreshes and ensures the checkout, publishing nothing, when the lock is free', async () => { + vi.mocked(acquireLock).mockResolvedValue(true); + mocks.isGitRepo.mockResolvedValue(true); + + await expect(readableReportsWorktree(config)).resolves.toBe(WT); + + expect(mocks.worktreeGit.fetch).toHaveBeenCalled(); + expect(mocks.worktreeGit.push).not.toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalledOnce(); + }); +}); + describe('updateReports', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/reports-readers-busy.test.ts b/src/__tests__/reports-readers-busy.test.ts new file mode 100644 index 000000000..ce4a99518 --- /dev/null +++ b/src/__tests__/reports-readers-busy.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Real-git test (#808): every checkout of a self-mode repo shares the reports +// checkout in the partition, so a reader must not create it while a write +// holds the reports lock: the holder may be creating it right now. + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +const { showStats } = await import('../stats.js'); +const { resolveVizRoot } = await import('../viz.js'); +const { resolveAnchors } = await import('../utils/git.js'); +const { resolvePartitionDir } = await import('../utils/partition.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.co', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.co', +}; + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } }); +} + +describe('stats and viz while a write holds the reports lock (#808)', () => { + let testRoot: string; + let originalHome: string | undefined; + let businessRoot: string; + let partition: string; + + beforeEach(async () => { + testRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-reports-busy-808-'))); + originalHome = process.env.HOME; + process.env.HOME = path.join(testRoot, 'home'); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + businessRoot = path.join(testRoot, 'business'); + const remote = path.join(testRoot, 'remote.git'); + fs.mkdirSync(path.join(businessRoot, '.teamai', 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(businessRoot, '.teamai', 'learnings', '.gitkeep'), ''); + git(['init', '-q', '--bare', remote], testRoot); + git(['init', '-q', '-b', 'main'], businessRoot); + git(['config', 'user.email', 't@t.co'], businessRoot); + git(['config', 'user.name', 't'], businessRoot); + git(['add', '-A'], businessRoot); + git(['commit', '-q', '-m', 'init'], businessRoot); + git(['remote', 'add', 'origin', remote], businessRoot); + git(['push', '-q', '-u', 'origin', 'main'], businessRoot); + + const anchors = await resolveAnchors(businessRoot); + if (!anchors) throw new Error('expected the business repo to have git anchors'); + partition = await resolvePartitionDir(anchors.projectAnchor); + fs.mkdirSync(partition, { recursive: true }); + fs.writeFileSync(path.join(partition, 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(businessRoot, '.teamai')}`, + ` remote: ${remote}`, + ' kind: self', + ` businessRepoRoot: ${businessRoot}`, + 'username: test', + 'scope: project', + `projectRoot: ${businessRoot}`, + 'additionalRoles: []', + '', + ].join('\n')); + // A live holder: this test's own process, as another teamai command would be. + fs.writeFileSync( + path.join(partition, '.reports-lock'), + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'issue-808-test' }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it('stats leaves the missing reports checkout to the lock holder', async () => { + const cwd = process.cwd(); + process.chdir(businessRoot); + try { + await showStats(); + } finally { + process.chdir(cwd); + } + + expect(fs.existsSync(path.join(partition, 'reports-wt'))).toBe(false); + }); + + it('viz leaves the missing reports checkout to the lock holder', async () => { + const config = { + repo: { + localPath: path.join(businessRoot, '.teamai'), + kind: 'self' as const, + businessRepoRoot: businessRoot, + remote: path.join(testRoot, 'remote.git'), + }, + username: 'test', + additionalRoles: [], + scope: 'project' as const, + projectRoot: businessRoot, + dataHome: partition, + }; + + await resolveVizRoot({ config }); + + expect(fs.existsSync(path.join(partition, 'reports-wt'))).toBe(false); + }); +}); diff --git a/src/__tests__/reports-votes-owner.test.ts b/src/__tests__/reports-votes-owner.test.ts new file mode 100644 index 000000000..bf7fcc232 --- /dev/null +++ b/src/__tests__/reports-votes-owner.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Real-git test (#808): self and git mode keep teamai-reports' checkout at the +// same partition path, so after a mode switch it may be another repository's. +// Its votes are that team's: nothing that ranks or downvotes may read them. + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + isSilent: () => false, +})); + +const { indexableVotesDir } = await import('../utils/reports-branch.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.co', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.co', +}; + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } }); +} + +describe('indexableVotesDir (#808)', () => { + let testRoot: string; + let businessRoot: string; + let dataHome: string; + + beforeEach(() => { + testRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-votes-808-'))); + businessRoot = path.join(testRoot, 'business'); + dataHome = path.join(testRoot, 'partition'); + fs.mkdirSync(businessRoot, { recursive: true }); + git(['init', '-q', '-b', 'main'], businessRoot); + git(['commit', '-q', '--allow-empty', '-m', 'init'], businessRoot); + }); + + afterEach(() => { + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + const config = () => ({ + repo: { localPath: path.join(businessRoot, '.teamai'), kind: 'self' as const, businessRepoRoot: businessRoot, remote: '' }, + username: 'test', + additionalRoles: [], + scope: 'project' as const, + projectRoot: businessRoot, + dataHome, + }); + + it("is this project's votes dir when no checkout is there yet", async () => { + expect(await indexableVotesDir(config())).toBe(path.join(dataHome, 'reports-wt', 'votes')); + }); + + it("is undefined when the reports checkout belongs to another repository", async () => { + const team = path.join(testRoot, 'team'); + git(['init', '-q', '-b', 'teamai-reports', team], testRoot); + fs.mkdirSync(path.join(team, 'votes'), { recursive: true }); + fs.writeFileSync(path.join(team, 'votes', 'test.yaml'), 'version: 2\nvotes: {}\ndeltas: {}\n'); + git(['add', '-A'], team); + git(['commit', '-q', '-m', 'reports'], team); + git(['checkout', '-q', '-b', 'main'], team); + git(['worktree', 'add', '-q', path.join(dataHome, 'reports-wt'), 'teamai-reports'], team); + + expect(await indexableVotesDir(config())).toBeUndefined(); + }); +}); diff --git a/src/__tests__/search-index-drop-all.test.ts b/src/__tests__/search-index-drop-all.test.ts new file mode 100644 index 000000000..cfafce40d --- /dev/null +++ b/src/__tests__/search-index-drop-all.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// #808: git and self mode share the data home, so the indexes an install built +// point into the other repository once `init` changes its kind. + +const { dropAllSearchIndexes } = await import('../utils/search-index.js'); + +describe('dropAllSearchIndexes (#808)', () => { + let dataHome: string; + + beforeEach(() => { + dataHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-drop-indexes-'))); + }); + + afterEach(() => { + fs.rmSync(dataHome, { recursive: true, force: true }); + }); + + const write = (rel: string): string => { + const file = path.join(dataHome, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '{}'); + return file; + }; + + it('deletes the root index and every checkout index, and nothing else', async () => { + const root = write('search-index.json'); + const a = write('workspaces/a/search-index.json'); + const b = write('workspaces/b/search-index.json'); + const mcp = write('workspaces/a/mcp.json'); + const queued = write('pending-learnings/note.md'); + + await dropAllSearchIndexes(dataHome); + + expect([root, a, b].filter((f) => fs.existsSync(f))).toEqual([]); + expect([mcp, queued].filter((f) => fs.existsSync(f))).toEqual([mcp, queued]); + }); + + it('succeeds when no index was ever built', async () => { + await expect(dropAllSearchIndexes(dataHome)).resolves.toBeUndefined(); + }); +}); diff --git a/src/__tests__/stats-foreign-reports.test.ts b/src/__tests__/stats-foreign-reports.test.ts new file mode 100644 index 000000000..86f711b1e --- /dev/null +++ b/src/__tests__/stats-foreign-reports.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import YAML from 'yaml'; + +// Real-git test (#808): self and git mode keep the reports checkout at the +// same partition path, so after a mode switch the checkout there can belong to +// another repository. `teamai stats` must read neither that team's totals nor +// its stats file's mtime, which would date this scope's last report. + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +const { showStats } = await import('../stats.js'); +const { resolveAnchors } = await import('../utils/git.js'); +const { resolvePartitionDir } = await import('../utils/partition.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.co', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.co', +}; + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } }); +} + +describe('stats with another repository\'s reports checkout in the partition (#808)', () => { + let testRoot: string; + let originalHome: string | undefined; + + beforeEach(() => { + testRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-stats-foreign-808-'))); + originalHome = process.env.HOME; + process.env.HOME = path.join(testRoot, 'home'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it('reads neither that checkout\'s totals nor its stats file\'s mtime', async () => { + const out: string[] = []; + vi.spyOn(console, 'log').mockImplementation((line) => { out.push(String(line)); }); + + const businessRoot = path.join(testRoot, 'business'); + const remote = path.join(testRoot, 'remote.git'); + fs.mkdirSync(path.join(businessRoot, '.teamai', 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(businessRoot, '.teamai', 'learnings', '.gitkeep'), ''); + git(['init', '-q', '--bare', remote], testRoot); + git(['init', '-q', '-b', 'main'], businessRoot); + git(['config', 'user.email', 't@t.co'], businessRoot); + git(['config', 'user.name', 't'], businessRoot); + git(['add', '-A'], businessRoot); + git(['commit', '-q', '-m', 'init'], businessRoot); + git(['remote', 'add', 'origin', remote], businessRoot); + git(['push', '-q', '-u', 'origin', 'main'], businessRoot); + const anchors = await resolveAnchors(businessRoot); + if (!anchors) throw new Error('expected the business repo to have git anchors'); + const partition = await resolvePartitionDir(anchors.projectAnchor); + fs.mkdirSync(partition, { recursive: true }); + fs.writeFileSync(path.join(partition, 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(businessRoot, '.teamai')}`, + ` remote: ${remote}`, + ' kind: self', + ` businessRepoRoot: ${businessRoot}`, + 'username: test', + 'scope: project', + `projectRoot: ${businessRoot}`, + 'additionalRoles: []', + '', + ].join('\n')); + // The scope's own snapshot: with it, snapshotWrittenAt reads the reports + // checkout's stats file too. + fs.mkdirSync(path.join(partition, 'dashboard'), { recursive: true }); + fs.writeFileSync(path.join(partition, 'dashboard', 'reported-prompt-tokens.json'), '{}'); + + // The other team's repository, with its checkout of teamai-reports where + // this project's would be, holding totals for the same username. + const teamRemote = path.join(testRoot, 'team.git'); + const teamSeed = path.join(testRoot, 'team-seed'); + git(['init', '-q', '--bare', teamRemote], testRoot); + git(['init', '-q', '-b', 'teamai-reports', teamSeed], testRoot); + fs.mkdirSync(path.join(teamSeed, 'stats'), { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'stats', 'test.yaml'), YAML.stringify({ + username: 'test', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 3, + tokens: { input: 1, output: 1, cacheRead: 0, cacheCreation: 0 }, + interventions: { sessions: 3, interrupt: 0, toolReject: 0, correction: 0 }, + })); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'reports'], teamSeed); + git(['remote', 'add', 'origin', teamRemote], teamSeed); + git(['push', '-q', 'origin', 'teamai-reports'], teamSeed); + const teamClone = path.join(partition, 'team-repo'); + git(['clone', '-q', teamRemote, teamClone], testRoot); + const foreignCheckout = path.join(partition, 'reports-wt'); + git(['worktree', 'add', '-q', foreignCheckout, 'teamai-reports'], teamClone); + + const stat = vi.spyOn(fs.promises, 'stat'); + const cwd = process.cwd(); + process.chdir(businessRoot); + try { + await showStats(); + } finally { + process.chdir(cwd); + } + + expect(stat.mock.calls.map(([file]) => String(file))).not.toContain(path.join(foreignCheckout, 'stats', 'test.yaml')); + expect(out.join('\n')).not.toMatch(/Sessions:\s+3/); + }); +}); diff --git a/src/__tests__/viz-foreign-checkout.test.ts b/src/__tests__/viz-foreign-checkout.test.ts new file mode 100644 index 000000000..151da2e0e --- /dev/null +++ b/src/__tests__/viz-foreign-checkout.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Real-git test (#808): self and git mode keep the learnings checkout at the +// same partition path, so after a mode switch the checkout there can belong to +// another repository. The knowledge dashboard must not build its index or its +// promotion candidates from that team's learnings. + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + isSilent: () => false, +})); + +const { resolveVizRoot } = await import('../viz.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.co', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.co', +}; + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } }); +} + +describe('viz with another repository\'s learnings checkout in the partition (#808)', () => { + let testRoot: string; + + beforeEach(() => { + testRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-viz-808-'))); + }); + + afterEach(() => { + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it('leaves that checkout out of the dashboard\'s learnings roots', async () => { + const businessRoot = path.join(testRoot, 'business'); + const remote = path.join(testRoot, 'remote.git'); + const dataHome = path.join(testRoot, 'partition'); + fs.mkdirSync(path.join(businessRoot, '.teamai', 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(businessRoot, '.teamai', 'learnings', 'own-2026-01-01-aaaaaa.md'), '# Own learning\n'); + git(['init', '-q', '--bare', remote], testRoot); + git(['init', '-q', '-b', 'main'], businessRoot); + // The reports checkout commits in-process, with the repo's identity: a CI + // runner has no global one. + git(['config', 'user.email', 't@t.co'], businessRoot); + git(['config', 'user.name', 't'], businessRoot); + git(['add', '-A'], businessRoot); + git(['commit', '-q', '-m', 'init'], businessRoot); + git(['remote', 'add', 'origin', remote], businessRoot); + git(['push', '-q', '-u', 'origin', 'main'], businessRoot); + + // The other team's repository, with its checkout of teamai-learnings where + // this project's would be. + const teamRemote = path.join(testRoot, 'team.git'); + const teamSeed = path.join(testRoot, 'team-seed'); + git(['init', '-q', '--bare', teamRemote], testRoot); + git(['init', '-q', '-b', 'teamai-learnings', teamSeed], testRoot); + fs.mkdirSync(path.join(teamSeed, 'learnings'), { recursive: true }); + fs.writeFileSync(path.join(teamSeed, 'learnings', 'foreign-2026-01-01-bbbbbb.md'), '# Foreign learning\n'); + git(['add', '-A'], teamSeed); + git(['commit', '-q', '-m', 'learnings'], teamSeed); + git(['remote', 'add', 'origin', teamRemote], teamSeed); + git(['push', '-q', 'origin', 'teamai-learnings'], teamSeed); + const teamClone = path.join(dataHome, 'team-repo'); + git(['clone', '-q', teamRemote, teamClone], testRoot); + const foreignCheckout = path.join(dataHome, 'learnings-wt'); + git(['worktree', 'add', '-q', foreignCheckout, 'teamai-learnings'], teamClone); + + const config = { + repo: { localPath: path.join(businessRoot, '.teamai'), kind: 'self' as const, businessRepoRoot: businessRoot, remote }, + username: 'test', + additionalRoles: [], + scope: 'project' as const, + projectRoot: businessRoot, + dataHome, + }; + + const paths = await resolveVizRoot({ config }); + + expect(paths.learningsDirs).not.toContain(path.join(foreignCheckout, 'learnings')); + // This project's own learnings stay. + expect(paths.learningsDirs).toContain(path.join(businessRoot, '.teamai', 'learnings')); + }); +}); diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index da31d1c4a..a1de5e4e8 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -37,6 +37,10 @@ vi.mock('../utils/reports-branch.js', async () => { return { updateReports: vi.fn(async (config: LocalConfig, write: (wt: string) => Promise) => (await write(checkout(config))) !== null), ensureReportsWorktree: vi.fn(async (config: LocalConfig) => checkout(config)), + readableReportsWorktree: vi.fn(async (config: LocalConfig) => checkout(config)), + // This project's own checkout, so its team votes count. + indexableVotesDir: vi.fn(async (config: LocalConfig) => + nodePath.join(nodePath.dirname(config.repo.localPath), 'reports-wt', 'votes')), }; }); diff --git a/src/contribute.ts b/src/contribute.ts index f73b4ff63..2bde9d058 100644 --- a/src/contribute.ts +++ b/src/contribute.ts @@ -5,13 +5,13 @@ import { assertNotReadOnly } from './read-only.js'; import { pathExists } from './utils/fs.js'; import { log, spinner } from './utils/logger.js'; import { markContributed } from './contribute-check.js'; -import { pendingLearningsDir, savePendingLearning } from './utils/pending-learnings.js'; +import { pendingLearningsDir, queueOwner, queueWriteRefusal, savePendingLearning } from './utils/pending-learnings.js'; import { publishQueuedLearnings } from './utils/learnings-publish.js'; -import { learningsRoots } from './utils/learnings-roots.js'; +import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { resolveActiveLearningsNamespaces } from './projects.js'; import { isSafeNamespaceSegment } from './manifest-schema.js'; import type { GlobalOptions, LocalConfig } from './types.js'; -import { getDataHome, getReportsDir, isSelfMode } from './types.js'; +import { getBusinessRoot, getDataHome, getProjectSearchIndexPath, isSelfMode } from './types.js'; /** * Decide which learnings subdirectory a contribution lands in — resolved from @@ -55,21 +55,25 @@ export async function rebuildIndexAfterContribute(localConfig: LocalConfig): Pro const repoPath = localConfig.repo.localPath; const docsRepoDir = path.join(repoPath, 'docs'); const rulesRepoDir = path.join(repoPath, 'rules'); - const votesDir = path.join(getReportsDir(localConfig), 'votes'); + // Not another repository's reports checkout (#808). + const { indexableVotesDir } = await import('./utils/reports-branch.js'); + const votesDir = await indexableVotesDir(localConfig); const activeLearningsNamespaces = await resolveActiveLearningsNamespaces( repoPath, localConfig.projects ?? [], ); - const teamaiHome = getDataHome(localConfig); - const indexPath = path.join(teamaiHome, 'search-index.json'); - const { buildIndex } = await import('./utils/search-index.js'); + const indexPath = getProjectSearchIndexPath(localConfig); + const { buildIndex, dropOtherCheckoutIndexes } = await import('./utils/search-index.js'); const { deliveredIndexSources } = await import('./resources/desired.js'); + await dropOtherCheckoutIndexes(localConfig); await buildIndex({ + // Without another repository's learnings checkout, if one sits where this + // project's would (#808). learningsDirs: [ pendingLearningsDir(localConfig), - ...learningsRoots(localConfig).read, + ...await indexableLearningsRoots(localConfig), ], // Manifest-resolved namespaces — MUST match what pull indexes by, or a // contribute-time rebuild drops the project's other learnings from recall. @@ -78,7 +82,7 @@ export async function rebuildIndexAfterContribute(localConfig: LocalConfig): Pro rulesDir: (await pathExists(rulesRepoDir)) ? rulesRepoDir : undefined, // The docs and skills pull delivers here, not the whole trees (#707). ...await deliveredIndexSources(localConfig), - votesDir: (await pathExists(votesDir)) ? votesDir : undefined, + votesDir: votesDir && (await pathExists(votesDir)) ? votesDir : undefined, indexPath, }); } @@ -98,6 +102,44 @@ export async function rebuildIndexAfterContribute(localConfig: LocalConfig): Pro // └─ done // +/** + * A queue an older teamai kept in this checkout's `.teamai/` is deleted with a + * linked worktree; move it into the shared queue, so the publish that follows + * sends it too (#808), or aside when the partition now serves another install + * (settleCheckoutQueue). The migration before contribute and import --from-mr + * does it too, and they stop when the queue would stay in the checkout + * (queueKeptInCheckout); this is a second pass. Best effort: the learning + * being queued must still be saved, and the old queue stays where it is. + */ +export async function drainCheckoutQueue(localConfig: LocalConfig): Promise { + if (!isSelfMode(localConfig)) return; + const legacyDir = path.join(getBusinessRoot(localConfig), '.teamai'); + const dataHome = getDataHome(localConfig); + if (path.resolve(legacyDir) === path.resolve(dataHome)) return; + const { settleCheckoutQueue } = await import('./migrate.js'); + try { + await settleCheckoutQueue(legacyDir, dataHome, queueOwner(localConfig)); + } catch (e) { + log.warn( + `Could not move the learnings an older teamai queued in ${legacyDir} ` + + `(${e instanceof Error ? e.message : String(e)}). They stay there; the next teamai pull moves them.`, + ); + } +} + +/** + * Where a learning saved for an install that changed before it was published + * is, for the member: still queued, or set aside with the previous install's + * queue by the `init` that switched it, which names the directory. + */ +export const KEPT_FOR_ITS_INSTALL = + 'It stays on this machine: in the queue, or where `teamai init` set that install\'s queue aside.'; + +/** What happens to a learning a checkout refusal kept from publishing: every pull meets the refusal too. */ +export const KEPT_UNTIL_CHECKOUT_SETTLED = + 'It stays queued and recallable here, but no `teamai pull` can publish it until that checkout is dealt with: ' + + 'do what the refusal says, then run `teamai pull`.'; + /** * Generate a safe filename for a contribution document. * @@ -124,7 +166,8 @@ export function generateFilename(title?: string): string { * The contribution is written to the durable queue first and published from * there. Nothing about it depends on the network, on push rights, or on a git * operation succeeding right now: what cannot be published stays queued and the - * next `teamai pull` publishes it. + * next `teamai pull` publishes it, once any checkout refusal that stopped it is + * dealt with. */ export async function contribute( options: GlobalOptions & { file?: string; title?: string; sessionId?: string; scope?: string }, @@ -184,10 +227,16 @@ export async function contribute( if (isSelfMode(localConfig)) { const { migrateSelfModeGitignore } = await import('./init.js'); await migrateSelfModeGitignore(localConfig); + await drainCheckoutQueue(localConfig); } try { - await savePendingLearning(localConfig, relPath, content); + const queued = await savePendingLearning(localConfig, relPath, content); + if (queued.status !== 'saved') { + spin.fail(queueWriteRefusal(queued)); + process.exitCode = 1; + return; + } } catch (e) { spin.fail(`Contribution failed: ${(e as Error).message}`); log.info('You can retry with: teamai contribute --file '); @@ -232,9 +281,13 @@ export async function contribute( log.info('Your session knowledge has been shared with the team.'); return; } + if (report.installChanged) { + spin.warn(`Saved locally, not published: ${report.installChanged}. ${KEPT_FOR_ITS_INSTALL}`); + return; + } spin.warn( `Saved locally (${report.lastError ?? 'not published yet'}). ` - + 'It stays recallable here and the next `teamai pull` publishes it.', + + (report.refused ? KEPT_UNTIL_CHECKOUT_SETTLED : 'It stays recallable here and the next `teamai pull` publishes it.'), ); } diff --git a/src/digest.ts b/src/digest.ts index 35bfbd244..199834071 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -417,10 +417,9 @@ export async function generateDigest(options: GlobalOptions): Promise { let reportsRoot = repoPath; const { usesBranchWorktree } = await import('./types.js'); if (usesBranchWorktree(localConfig)) { - const { ensureReportsWorktree, refreshReportsWorktree } = await import('./utils/reports-branch.js'); + const { readableReportsWorktree } = await import('./utils/reports-branch.js'); // Read-only: never publish a missing reports branch. - await refreshReportsWorktree(localConfig, { pushIfCreated: false }); - reportsRoot = await ensureReportsWorktree(localConfig, { pushIfCreated: false }); + reportsRoot = await readableReportsWorktree(localConfig); } const teamStats = await loadTeamStats(reportsRoot); @@ -483,9 +482,11 @@ export async function generateDigest(options: GlobalOptions): Promise { } // Learnings - const { learningsRoots } = await import('./utils/learnings-roots.js'); + // Not another repository's learnings checkout, if one sits where this + // project's would (#808). + const { indexableLearningsRoots } = await import('./utils/learnings-roots.js'); const { recent: recentLearnings, total: totalLearnings } = await getRecentLearnings( - learningsRoots(localConfig).read, + await indexableLearningsRoots(localConfig), ); if (recentLearnings.length > 0) { console.log(`📚 New Learnings This Week: ${recentLearnings.length}`); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 4433115ce..e8f46e5f5 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -608,7 +608,17 @@ const votesJudgeHandler: HookHandler = { try { const { learningsRoots } = await import('./utils/learnings-roots.js'); const { pendingLearningsDir } = await import('./utils/pending-learnings.js'); - allowedRoots.push(...learningsRoots(localConfig).read, pendingLearningsDir(localConfig)); + const { learningsBranch } = await import('./utils/learnings-branch.js'); + // Not another repository's learnings checkout, if one sits where this + // project's would (#808): judged from git's files, since a hook starts + // no git process. + const checkout = learningsBranch.dir(localConfig); + const foreign = await learningsBranch.isForeignByFiles(localConfig); + const inCheckout = (root: string) => root === checkout || root.startsWith(`${checkout}${path.sep}`); + allowedRoots.push( + ...learningsRoots(localConfig).read.filter((root) => !foreign || !inCheckout(root)), + pendingLearningsDir(localConfig), + ); } catch (e) { log.debug(`votes-judge: could not resolve extra learnings roots: ${(e as Error).message}`); } diff --git a/src/import-mr.ts b/src/import-mr.ts index 464f73269..29b5b82f7 100644 --- a/src/import-mr.ts +++ b/src/import-mr.ts @@ -175,7 +175,7 @@ function extractRepoUrlFromMrUrl(mrUrl: string): string { * @param opts.learningsDirs Directories scanned for a superseded draft * @param opts.all Skip interactive confirmation, accept all * @param opts.outputDir Output mode: write to this directory (learning.md) - * @param opts.writeLearningsDir Where a new learning is written when outputDir is not set + * @param opts.queueLearning Queues a new learning (its file name, its content) when outputDir is not set, returning the file written * @param opts.dryRun Dry run, no disk writes * @returns Extraction result containing the learning draft and inferred repo URL */ @@ -185,7 +185,7 @@ export async function importFromMR(opts: { learningsDirs?: readonly string[]; all?: boolean; outputDir?: string; - writeLearningsDir?: string; + queueLearning?: (filename: string, content: string) => Promise; dryRun?: boolean; }): Promise<{ learning?: LearningDraft; repoUrl: string; learningFile?: string }> { const learningsDirs = opts.learningsDirs ?? [DEFAULT_LEARNINGS_DIR]; @@ -255,7 +255,7 @@ export async function importFromMR(opts: { // ── 步骤 6:写文件 ───────────────────────────────────── let learningFile: string | undefined; if (!opts.dryRun && acceptLearning) { - learningFile = await writeLearning(learning, opts.outputDir, opts.writeLearningsDir); + learningFile = await writeLearning(learning, opts.outputDir, opts.queueLearning); } // 推断仓库 URL @@ -271,7 +271,7 @@ export async function importFromMR(opts: { /** * 将 learning 草稿写入磁盘。 * - * Writes to outputDir when given, else to learningsDir, and returns the file written. + * Writes to outputDir when given, else into the queue, and returns the file written. * With neither, it warns and skips. * * @param draft The learning draft @@ -281,7 +281,7 @@ export async function importFromMR(opts: { async function writeLearning( draft: LearningDraft, outputDir?: string, - learningsDir?: string, + queueLearning?: (filename: string, content: string) => Promise, ): Promise { if (outputDir) { await fs.mkdir(outputDir, { recursive: true }); @@ -291,13 +291,11 @@ async function writeLearning( return filePath; } - if (learningsDir) { - await fs.mkdir(learningsDir, { recursive: true }); + if (queueLearning) { // contribute's naming: the random suffix keeps two members' learnings with // the same title and day apart once both are published (#823). const { generateFilename } = await import('./contribute.js'); - const filePath = path.join(learningsDir, generateFilename(draft.title)); - await fs.writeFile(filePath, draft.content, 'utf-8'); + const filePath = await queueLearning(generateFilename(draft.title), draft.content); log.info(`Learning written: ${filePath}`); return filePath; } diff --git a/src/import.ts b/src/import.ts index 02c83a6de..b84d92252 100644 --- a/src/import.ts +++ b/src/import.ts @@ -10,8 +10,8 @@ import { importFromRepo } from './import-repo.js'; import { importFromRepoList } from './import-repo-list.js'; import { importFromOrg } from './import-org.js'; import { importFromIWikiDual } from './iwiki-dual.js'; -import { learningsRoots } from './utils/learnings-roots.js'; -import { pendingLearningsDir } from './utils/pending-learnings.js'; +import { indexableLearningsRoots } from './utils/learnings-roots.js'; +import { pendingLearningsDir, queueWriteRefusal, savePendingLearning } from './utils/pending-learnings.js'; import type { GlobalOptions, LearningDraft } from './types.js'; import { assertNotReadOnly } from './read-only.js'; import { Listr, PRESET_TIMER } from 'listr2'; @@ -245,8 +245,10 @@ export async function importCmd(opts: ImportOptions): Promise { if (!opts.dryRun && !opts.output) assertNotReadOnly(localConfig, 'teamai import --from-mr'); // As contribute: into the active project's learnings namespace when there // is exactly one, else the shared root. - const { resolveLearningsSubdir } = await import('./contribute.js'); + const { drainCheckoutQueue, resolveLearningsSubdir } = await import('./contribute.js'); const learningsSubdir = opts.dryRun || opts.output ? '' : await resolveLearningsSubdir(localConfig); + // As contribute, before the extraction dedupes against the queue it writes into. + if (!opts.dryRun && !opts.output) await drainCheckoutQueue(localConfig); const tasks = new Listr([ { @@ -254,11 +256,16 @@ export async function importCmd(opts: ImportOptions): Promise { task: async (ctx) => { const { learning, repoUrl, learningFile } = await importFromMR({ url: opts.fromMr!, - learningsDirs: [pendingLearningsDir(localConfig), ...learningsRoots(localConfig).read], + // Not another repository's learnings checkout (#808). + learningsDirs: [pendingLearningsDir(localConfig), ...(await indexableLearningsRoots(localConfig))], all: opts.all, outputDir: opts.output, // Into the contribution queue, which publishing drains (#823). - writeLearningsDir: opts.dryRun ? undefined : path.join(pendingLearningsDir(localConfig), learningsSubdir), + queueLearning: opts.dryRun ? undefined : async (filename, content) => { + const queued = await savePendingLearning(localConfig, path.posix.join(learningsSubdir, filename), content); + if (queued.status !== 'saved') throw new Error(queueWriteRefusal(queued)); + return queued.path; + }, dryRun: opts.dryRun, }); ctx.learning = learning; @@ -277,7 +284,13 @@ export async function importCmd(opts: ImportOptions): Promise { await rebuildIndexAfterContribute(localConfig).catch((e: unknown) => log.debug(`import: index rebuild skipped: ${e instanceof Error ? e.message : String(e)}`)); const queued = ctx.learningFile ? path.posix.join(learningsSubdir, path.basename(ctx.learningFile)) : ''; - if (!report.published.includes(queued)) { + if (report.installChanged) { + const { KEPT_FOR_ITS_INSTALL } = await import('./contribute.js'); + task.title = `Learning saved locally, not published: ${report.installChanged}. ${KEPT_FOR_ITS_INSTALL}`; + } else if (report.refused) { + const { KEPT_UNTIL_CHECKOUT_SETTLED } = await import('./contribute.js'); + task.title = `Learning saved locally (${report.lastError ?? 'not published yet'}). ${KEPT_UNTIL_CHECKOUT_SETTLED}`; + } else if (!report.published.includes(queued)) { task.title = `Learning saved locally (${report.lastError ?? 'not published yet'}); ` + 'the next `teamai pull` publishes it'; } diff --git a/src/index.ts b/src/index.ts index 53fb1142e..1d84f6e48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { setVerbose, setSilent, log } from './utils/logger.js'; import { applyNonInteractiveGitEnv } from './utils/git-env.js'; import { ensureBundledRuntimeOnPath } from './bundled-runtime.js'; import type { GlobalOptions, LocalConfig } from './types.js'; +import type { MaintenancePaths } from './maintenance/paths.js'; import { TEAMAI_HOOK_SUBCOMMANDS } from './hooks.js'; import { registerPackagesCommand } from './pkg/register-command.js'; @@ -12,6 +13,39 @@ import { registerPackagesCommand } from './pkg/register-command.js'; // on the double-read fallback, and hook-dispatch is excluded outright (see below). const MIGRATION_TRIGGER_COMMANDS = new Set(['init', 'pull', 'push']); +/** + * Whether this command queues learnings, which an unmigrated checkout would + * keep in its own `.teamai/`, deleted with a linked worktree (#808). `import` + * queues them only with --from-mr; its other modes leave the queue alone, and + * `--cache-status --json` must print nothing but its JSON. `contribute --scope + * user` queues in the user install, and `import --from-mr --output` writes + * drafts only. + */ +function queuesLearnings(command: Command): boolean { + const name = command.name(); + const opts = command.opts(); + return ( + (name === 'contribute' && opts.scope !== 'user') || + (name === 'import' && opts.fromMr !== undefined && opts.output === undefined) + ); +} + +/** + * Whether this command stops while this checkout's queue would stay in its + * `.teamai/`: one that queues learnings there, and a project `teamai init` + * (not `roles init`), which would set the project up and leave that queue to + * be deleted with the worktree (#808). + */ +function needsQueueOutOfCheckout(command: Command): boolean { + const projectInit = command.name() === 'init' && command.parent?.parent === null && command.opts().scope !== 'user'; + return queuesLearnings(command) || projectInit; +} + +/** Whether this command migrates first. */ +function triggersMigration(command: Command): boolean { + return MIGRATION_TRIGGER_COMMANDS.has(command.name()) || queuesLearnings(command); +} + const require = createRequire(import.meta.url); const { version } = require('../package.json'); @@ -49,16 +83,17 @@ program if (opts.verbose) setVerbose(true); // Auto-migrate a legacy `/.teamai/` into the partition before the - // command runs, so init/pull/push (and every path resolver they call) see - // the migrated layout. Narrowed twice: hook-dispatch is a high-frequency + // command runs, so the trigger commands (and every path resolver they call) + // see the migrated layout. Narrowed twice: hook-dispatch is a high-frequency // silent path that must never move 12MB, and only write commands trigger a // move (read-only commands use the double-read fallback). Dry-run previews. const name = actionCommand.name(); if (TEAMAI_HOOK_SUBCOMMANDS.includes(name as (typeof TEAMAI_HOOK_SUBCOMMANDS)[number])) return; - if (!MIGRATION_TRIGGER_COMMANDS.has(name)) return; - const { maybeMigrate } = await import('./migrate.js'); + if (!triggersMigration(actionCommand)) return; + const { maybeMigrate, queueKeptInCheckout } = await import('./migrate.js'); + let migration; try { - await maybeMigrate({ dryRun: !!opts.dryRun }); + migration = await maybeMigrate({ dryRun: !!opts.dryRun }); } catch (e) { // A failed migration must not proceed into the command on stale/partial // state. Surface a clean message and exit — the copy→verify→rename design @@ -68,6 +103,15 @@ program log.error('Your original .teamai data is unchanged. Re-run the command to retry.'); process.exit(1); } + // A learning queued in this checkout would go with it when the worktree is + // removed (#808). + if (needsQueueOutOfCheckout(actionCommand)) { + const kept = await queueKeptInCheckout(migration); + if (kept) { + log.error(kept); + process.exit(1); + } + } }); program @@ -1223,10 +1267,11 @@ recallCmd const { autoDetectInit } = await import('./config.js'); const { localConfig } = await autoDetectInit(); - const { resolveMaintenancePaths } = await import('./maintenance/index.js'); + const paths = await maintenancePathsOrExit(localConfig); + if (!paths) return; const { repoPath, votesDir, learningsReadDirs, learningsWriteDir, - } = await resolveMaintenancePaths(localConfig); + } = paths; if (cmdOpts.confidenceWriteback) { const { computeAllConfidence, writeBackConfidence } = await import('./maintenance/index.js'); @@ -1303,13 +1348,14 @@ recallCmd const { autoDetectInit } = await import('./config.js'); const { localConfig } = await autoDetectInit(); const { - resolveMaintenancePaths, findPromotionCandidates, executePromotion, } = await import('./maintenance/index.js'); + const paths = await maintenancePathsOrExit(localConfig); + if (!paths) return; const { repoPath, votesDir, learningsReadDirs, learningsWriteDir, - } = await resolveMaintenancePaths(localConfig); + } = paths; const { log } = await import('./utils/logger.js'); const candidates = await findPromotionCandidates(learningsReadDirs, votesDir); @@ -1345,6 +1391,31 @@ recallCmd }); +/** + * The maintenance paths, or undefined with exit code 1 when teamai refuses a + * side-branch checkout (#808): another repository's, whose learnings + * maintenance would rewrite, or an old one in the way of the shared checkout it + * would write into, which the next publish would then delete. The refusal names + * the checkout and the way out, and has already been printed. Also when the + * reports or learnings lock cannot be taken, so the checkout cannot be checked. + */ +async function maintenancePathsOrExit(localConfig: LocalConfig): Promise { + const { CheckoutLockedError, CheckoutUnavailableError, resolveMaintenancePaths } = await import('./maintenance/index.js'); + const { CheckoutRefusedError } = await import('./utils/branch-worktree.js'); + try { + return await resolveMaintenancePaths(localConfig); + } catch (e) { + if (e instanceof CheckoutLockedError || e instanceof CheckoutUnavailableError) { + const { log } = await import('./utils/logger.js'); + log.error(e.message); + } else if (!(e instanceof CheckoutRefusedError)) { + throw e; + } + process.exitCode = 1; + return undefined; + } +} + /** * Publish what a maintenance command just changed in the learnings worktree. * Best-effort: the change is already on disk, so a failure to publish is worth diff --git a/src/init.ts b/src/init.ts index b4bd1ef71..b6c3446f1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -8,6 +8,33 @@ import { pushRepoDirectly } from './utils/git.js'; import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; import { ensureDir, writeFile, writeFileAtomic, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; +import { queueOwner, sameQueueOwner, setAsideQueueOnModeSwitch } from './utils/pending-learnings.js'; +import { dropAllSearchIndexes } from './utils/search-index.js'; + +/** + * A re-init that changes the install's kind or team repository keeps the data + * home (git and self mode share the partition, #808; #823 item 13), and what it + * holds was written for the previous repository: set the queue aside and drop + * the search indexes, which the new install rebuilds from its own knowledge. + * A config that exists but cannot be read names no owner, so it counts as + * another. `save` writes the new config. + */ +async function settleModeSwitch(previous: LocalConfig | null, next: LocalConfig, save: () => Promise): Promise { + const switched = await setAsideQueueOnModeSwitch(previous, next, async () => { + const ownerChanged = previous + ? !sameQueueOwner(queueOwner(previous), queueOwner(next)) + : fs.existsSync(path.join(getDataHome(next), 'config.yaml')); + if (ownerChanged) await dropAllSearchIndexes(getDataHome(previous ?? next)); + await save(); + }); + if (switched.status === 'busy') { + log.error( + `Another teamai command is writing this project's queued learnings (${switched.lockPath} is held), ` + + 'so init did not save the new config. Run init again when it finishes.', + ); + process.exit(1); + } +} import { log, spinner } from './utils/logger.js'; import { CLAUDE_TOOL_ID, @@ -23,6 +50,7 @@ import { type Scope, getTeamaiHome, getConfigPath, + getDataHome, } from './types.js'; import { getUserHome } from './utils/home.js'; import { describeRoles, listRoleIds, loadRolesManifest, RolesManifestNotFoundError } from './roles.js'; @@ -554,12 +582,14 @@ export async function initHttp( await releasePreviousClaudeRoot(teamConfig, existingLocalConfig, localConfig); await ensureDir(teamaiHome); - if (scope === 'project') { - await saveLocalConfigForScope(localConfig, scope, projectRoot); - } else { - await ensureDir(getTeamaiHomeDir()); - await saveLocalConfig(localConfig); - } + await settleModeSwitch(existingLocalConfig, localConfig, async () => { + if (scope === 'project') { + await saveLocalConfigForScope(localConfig, scope, projectRoot); + } else { + await ensureDir(getTeamaiHomeDir()); + await saveLocalConfig(localConfig); + } + }); log.success(`Local config saved to ${teamaiHome}/config.yaml`); // Invalidate cache so the next pull does a full sync. @@ -1037,7 +1067,8 @@ export async function initSelfRepo(options: GlobalOptions & { // getDataHome, which now resolves to the partition. await ensureDir(teamaiHome); await ensureDir(partitionHome); - await saveLocalConfigForScope(localConfig, 'project', businessRepoRoot); + await settleModeSwitch(existingSelfConfig, localConfig, () => + saveLocalConfigForScope(localConfig, 'project', businessRepoRoot)); log.success(`Local config saved to ${partitionHome}/config.yaml`); // (Pre-P2 this retired any stale partition config so detection fell back to the // in-repo self config. P2 makes self USE the partition, so there is nothing to @@ -1717,9 +1748,11 @@ export async function init(options: GlobalOptions & { await releasePreviousClaudeRoot(currentConfig, existingLocalConfig, localConfig); await ensureDir(teamaiHome); + if (scope !== 'project') await ensureDir(getTeamaiHomeDir()); + await settleModeSwitch(existingLocalConfig, localConfig, () => + scope === 'project' ? saveLocalConfigForScope(localConfig, scope, projectRoot) : saveLocalConfig(localConfig)); if (scope === 'project') { - await saveLocalConfigForScope(localConfig, scope, projectRoot); log.success(`Local config saved to ${teamaiHome}/config.yaml`); // Generate .gitignore for project scope to prevent local config from being committed @@ -1749,8 +1782,6 @@ export async function init(options: GlobalOptions & { log.debug('Generated .teamai/.gitignore for project scope'); } } else { - await ensureDir(getTeamaiHomeDir()); - await saveLocalConfig(localConfig); log.success(`Local config saved to ${getTeamaiHomeDir()}/config.yaml`); } diff --git a/src/maintenance/index.ts b/src/maintenance/index.ts index b4781000d..ba6db6bed 100644 --- a/src/maintenance/index.ts +++ b/src/maintenance/index.ts @@ -7,5 +7,5 @@ export { findStaleEntries, reportStaleEntries, findRelatedAdoptedLearnings, gene export type { StaleEntry, QualityUpdateOptions } from './quality-update.js'; export { findPromotionCandidates, executePromotion } from './promote.js'; export type { PromotionCandidate, PromoteOptions } from './promote.js'; -export { resolveMaintenancePaths } from './paths.js'; +export { CheckoutLockedError, CheckoutUnavailableError, resolveMaintenancePaths } from './paths.js'; export type { MaintenancePaths } from './paths.js'; diff --git a/src/maintenance/paths.ts b/src/maintenance/paths.ts index 947fadffa..40dc82bce 100644 --- a/src/maintenance/paths.ts +++ b/src/maintenance/paths.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type { LocalConfig } from '../types.js'; +import type { RefreshResult } from '../utils/branch-worktree.js'; import { getKnowledgeDir, getReportsDir, usesBranchWorktree } from '../types.js'; import { learningsRoots } from '../utils/learnings-roots.js'; @@ -16,6 +17,51 @@ export interface MaintenancePaths { learningsReadDirs: readonly string[]; } +/** + * A side-branch lock could not be taken (#808): another command holds it, or + * it cannot be created. The checkout there is unchecked: it may be another + * repository's, or still being created, so maintenance must not read votes + * from it or write learnings under it. + */ +export class CheckoutLockedError extends Error { + override name = 'CheckoutLockedError'; + constructor(checkout: 'learnings' | 'reports', lockPath: string) { + super( + `The ${checkout} checkout is locked: another teamai command may be updating it, or its lock at ${lockPath} could not be created. ` + + 'Nothing was changed. Run this again when the other command finishes; ' + + `if this keeps happening, check that ${path.dirname(lockPath)} is writable.`, + ); + } +} + +/** + * A side-branch checkout could not be created or synced, for a reason other + * than a refusal (#808): maintenance would write into a directory that is no + * checkout, and the next publish would delete it. + */ +export class CheckoutUnavailableError extends Error { + override name = 'CheckoutUnavailableError'; + constructor(checkout: 'learnings' | 'reports', reason: string) { + super(`The ${checkout} checkout could not be set up: ${reason}. Nothing was changed. Fix that, then run this again.`); + } +} + +/** Throw unless the refresh left a checkout maintenance can read and write. */ +function assertCheckoutReady(checkout: 'learnings' | 'reports', refreshed: RefreshResult): void { + switch (refreshed.status) { + case 'done': + return; + case 'busy': + throw new CheckoutLockedError(checkout, refreshed.lockPath); + case 'failed': + throw new CheckoutUnavailableError(checkout, refreshed.reason); + default: { + const unhandled: never = refreshed; + throw new Error(`Unhandled refresh result: ${JSON.stringify(unhandled)}`); + } + } +} + /** * Resolve the knowledge and report roots used by recall maintenance commands. * @@ -23,18 +69,20 @@ export interface MaintenancePaths { * Votes live in the teamai-reports worktree for every non-HTTP repo. HTTP keeps * both data sets under localConfig.repo.localPath. Maintenance is a report * reader, so a cold start may create its local cache but never publishes a new - * reports branch as a side effect. + * reports branch as a side effect. Throws CheckoutLockedError when the + * reports or learnings lock cannot be taken, and CheckoutUnavailableError when + * either checkout could not be set up. */ export async function resolveMaintenancePaths( localConfig: LocalConfig, ): Promise { if (usesBranchWorktree(localConfig)) { const { refreshReportsWorktree } = await import('../utils/reports-branch.js'); - await refreshReportsWorktree(localConfig, { pushIfCreated: false }); + assertCheckoutReady('reports', await refreshReportsWorktree(localConfig, { pushIfCreated: false })); // Maintenance reads and rewrites learnings, so it needs the branch as other // members left it. Read-only: it never publishes a branch that is missing. const { learningsBranch } = await import('../utils/learnings-branch.js'); - await learningsBranch.refresh(localConfig, { pushIfCreated: false }); + assertCheckoutReady('learnings', await learningsBranch.refresh(localConfig, { pushIfCreated: false })); } const repoPath = getKnowledgeDir(localConfig); diff --git a/src/members.ts b/src/members.ts index 1c3771a40..f5b0afba6 100644 --- a/src/members.ts +++ b/src/members.ts @@ -98,9 +98,18 @@ export async function listMembers(options: GlobalOptions): Promise { let repoPath: string; const { usesBranchWorktree } = await import('./types.js'); if (usesBranchWorktree(localConfig)) { - const { ensureReportsWorktree, refreshReportsWorktree } = await import('./utils/reports-branch.js'); - await refreshReportsWorktree(localConfig, { pushIfCreated: false }); - repoPath = await ensureReportsWorktree(localConfig, { pushIfCreated: false }); + const { readableReportsWorktree } = await import('./utils/reports-branch.js'); + const { CheckoutRefusedError } = await import('./utils/branch-worktree.js'); + try { + repoPath = await readableReportsWorktree(localConfig); + } catch (e) { + // A reports checkout teamai refused (#808): another repository's, whose + // roster is not this team's, or an old one in the way. The refusal, with + // the way out, was already printed. + if (!(e instanceof CheckoutRefusedError)) throw e; + process.exitCode = 1; + return; + } } else { repoPath = localConfig.repo.localPath; await pullRepo(repoPath); diff --git a/src/migrate.ts b/src/migrate.ts index 285ac6ae7..365a32b5d 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -1,14 +1,16 @@ import path from 'node:path'; import fse from 'fs-extra'; import YAML from 'yaml'; -import { LocalConfigSchema, SYNC_LOCK_FILENAME, WORKTREE_DIRNAMES } from './types.js'; -import { resolveAnchors } from './utils/git.js'; +import { isSelfMode, LocalConfigSchema, SYNC_LOCK_FILENAME, WORKTREE_DIRNAMES, type LocalConfig } from './types.js'; +import { getRemoteUrl, resolveAnchors } from './utils/git.js'; import { resolvePartitionDir, writeAnchorFile } from './utils/partition.js'; import { realpath } from 'node:fs/promises'; -import { expandHome, pathExists, readFileSafe, remove, writeFile } from './utils/fs.js'; +import { expandHome, listFilesRecursive, pathExists, readFileIfExists, readFileSafe, remove, writeFile } from './utils/fs.js'; import { acquireLock, releaseLock } from './update.js'; -import { readConfigFrom } from './config.js'; +import { detectProjectConfig, readConfigFrom } from './config.js'; +import { getUserHome } from './utils/home.js'; import { log } from './utils/logger.js'; +import { acquireQueueLock, countQueued, pendingLearningsDir, queueOwner, sameQueueOwner, setAsideCheckoutQueue, withQueueLock, type QueueOwner } from './utils/pending-learnings.js'; /** * P1-3 automatic migration (issue #374). @@ -18,16 +20,17 @@ import { log } from './utils/logger.js'; * inside the business repo at `/.teamai/`. P1-2 flipped NEW * installs to the partition `~/.teamai/projects//` and reads old installs * via a legacy fallback. This module moves a real legacy `.teamai/` INTO the - * partition the first time a write command (`init`/`pull`/`push`) runs, so the - * business workspace ends up with zero teamai residue. + * partition the first time a write command (`init`/`pull`/`push`/`contribute`, + * `import --from-mr`) runs, so the business workspace ends up with zero teamai + * residue. * * Safety model (issue R2): copy → verify → atomic rename, so an interruption * never leaves the data half-in-both-places. The source is only renamed to * `.teamai.bak/` AFTER the partition is fully in place; we never delete it. * * Trigger is narrowed by the caller (the global preAction hook): hook-dispatch - * and read-only commands never reach here. self mode is a hard no-op (its - * `.teamai/` is team knowledge committed to main, not machine data). + * and read-only commands never reach here. self mode moves only its machine + * data (its `.teamai/` is team knowledge committed to main): see migrateSelfA1. */ /** Directories/files under a legacy `.teamai/` that must NOT be copied. */ @@ -65,20 +68,40 @@ function isSkippedEntry(name: string): boolean { // the member has already made, and nothing else has a copy of them, so it has // to travel with the partition. -export interface MigrationPlan { +export type MigrationPlan = { legacyDir: string; partitionDir: string; anchor: string; - /** - * 'full': copy legacy → partition, then retire the source. - * 'retire-only': the partition is already built (e.g. a prior run crashed - * between the partition rename and the source retire), so just clean up the - * leftover legacy dir. Without this, planMigration would return null on the - * "partition exists" check and the legacy dir — including its plaintext `env` - * — would linger in the workspace forever, breaking the zero-residue promise. - */ - mode: 'full' | 'retire-only' | 'self'; -} +} & ( + | { + /** + * 'full': copy legacy → partition, then retire the source. + * 'retire-only': the partition is already built (e.g. a prior run crashed + * between the partition rename and the source retire), so just clean up the + * leftover legacy dir. Without this, planMigration would return null on the + * "partition exists" check and the legacy dir — including its plaintext `env` + * — would linger in the workspace forever, breaking the zero-residue promise. + */ + mode: 'full' | 'retire-only'; + /** The install in the legacy dir, whose queue it may hold. */ + legacyOwner: QueueOwner; + } + | { + mode: 'self'; + /** The self install whose queue the legacy dir may hold. */ + legacyOwner: QueueOwner; + } + | { + /** + * 'superseded': the legacy dir holds `previousOwner`'s install beside + * the self knowledge checked out from main, and the partition serves the + * self install `init` set up from another checkout (#808). Its machine + * data goes aside; the knowledge stays. + */ + mode: 'superseded'; + previousOwner: QueueOwner; + } +); /** * Class-A1 machine-data entries under a self install's `/.teamai/` that P2 @@ -96,9 +119,54 @@ const SELF_A1_ENTRIES = [ 'state.json', 'env.local', 'env.sh', + 'managed-mcp.json', + 'workspaces', + 'config.yaml', +] as const; + +/** + * Machine data a self install no longer reads, deleted rather than moved: the + * search index is kept per checkout under `workspaces//` and rebuilt, so + * the old shared one would only sit unread in the partition (#808). + */ +const SELF_STALE_ENTRIES = ['search-index.json'] as const; + +/** + * The queue of unpublished learnings, which self mode kept in each checkout's + * `.teamai/` until #808. It moves file by file (drainLegacyQueue), because the + * partition queue may already hold other checkouts' learnings, or aside once + * the partition serves another install (settleCheckoutQueue). + */ +const SELF_LEGACY_QUEUE = 'pending-learnings'; + +/** Everything the self migration takes out of a checkout's `.teamai/`. */ +const SELF_LEGACY_ENTRIES = [SELF_LEGACY_QUEUE, ...SELF_STALE_ENTRIES, ...SELF_A1_ENTRIES] as const; + +/** + * What a git or http project install keeps in `.teamai/`: machine data only, + * none of it team knowledge. A superseded install's go to a backup (#808). + * Its `env` is a file; a directory of that name is the self install's env. + * + * ORDER MATTERS, as in SELF_A1_ENTRIES: `config.yaml` is what planMigration + * keys on, so it moves LAST. A move that fails partway leaves it in place, and + * the next run takes the same path for what is left. + */ +const SUPERSEDED_ENTRIES = [ + 'state.json', + 'token', + 'env', + 'env.sh', + 'env.local', + 'team-repo', 'search-index.json', 'managed-mcp.json', 'workspaces', + 'sessions', + 'votes', + 'dashboard', + 'usage.jsonl', + 'known-skills.json', + 'teamai.lock', 'config.yaml', ] as const; @@ -117,7 +185,9 @@ const SELF_A1_ENTRIES = [ * * When a readable partition config already exists AND a legacy dir still * lingers, returns a 'retire-only' plan to finish an interrupted migration - * instead of skipping. A partition config that exists but cannot be read skips. + * instead of skipping, or a 'superseded' one when that config is a self install + * and the legacy dir holds its knowledge. A partition config that exists but + * cannot be read skips. */ export async function planMigration(cwd?: string): Promise { const anchors = await resolveAnchors(cwd ?? process.cwd()); @@ -139,17 +209,26 @@ export async function planMigration(cwd?: string): Promise // key the whole decision on legacy config.yaml existing (that would go blind and // strand the remaining A1 forever). A malformed config is treated as "nothing to // migrate" rather than crashing a write command. - const gateContent = - (await readFileSafe(legacyConfig)) ?? - (await readFileSafe(path.join(partitionDir, 'config.yaml'))); + const legacyContent = await readFileSafe(legacyConfig); + const gateContent = legacyContent ?? (await readFileSafe(path.join(partitionDir, 'config.yaml'))); if (!gateContent) return null; let scope: string | undefined; let kind: string | undefined; + let legacyOwner: QueueOwner; try { const parsed = LocalConfigSchema.parse(YAML.parse(gateContent)); scope = parsed.scope; kind = parsed.repo.kind; - } catch { + legacyOwner = queueOwner(parsed); + } catch (e) { + // Its queue then stays, and contribute stops on it (queueKeptInCheckout). + if (legacyContent !== null && (await holdsQueued(path.join(legacyDir, SELF_LEGACY_QUEUE)))) { + log.warn( + `Kept the learnings queued in ${path.join(legacyDir, SELF_LEGACY_QUEUE)}: ${legacyConfig} cannot be read ` + + `(${(e instanceof Error ? e.message : String(e)).split('\n')[0]}), so teamai cannot tell which install ` + + 'they belong to. Fix that file; the next init, pull or push moves them.', + ); + } return null; } if (scope !== 'project') return null; @@ -162,9 +241,20 @@ export async function planMigration(cwd?: string): Promise // ANY A1 entry still sits in the repo — not just config.yaml — so an interrupted // relocation (config.yaml already moved, env.local not yet) is still finished. if (kind === 'self') { - const hasLegacyA1 = await anyExists(legacyDir, SELF_A1_ENTRIES); - if (!hasLegacyA1) return null; - return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'self' }; + const hasLegacyEntries = await anyExists(legacyDir, SELF_LEGACY_ENTRIES); + if (!hasLegacyEntries) return null; + // As for the other kinds (#797): the checkout's config is then the only one + // that loads, and the relocation would drop it for the broken one. + const partition = await readPartitionState(partitionDir, anchors.workspaceRoot); + if (partition.state === 'unreadable') { + log.warn( + `Kept the machine data in ${legacyDir}: ${partition.configPath} cannot be read ` + + `(${partition.error.split('\n')[0]}). If that persists, fix the file; the next init, pull or push ` + + `then moves it into ${partitionDir}.`, + ); + return null; + } + return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'self', legacyOwner }; } // Non-self (git/http): keyed on legacy config.yaml — the git-mode migration @@ -178,7 +268,13 @@ export async function planMigration(cwd?: string): Promise // legacy dir, so the workspace really does end up residue-free. const partition = await readPartitionState(partitionDir, anchors.workspaceRoot); if (partition.state === 'built') { - return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'retire-only' }; + // Another checkout switched the project to self mode, and this one has + // checked out the knowledge that switch committed: retiring the whole dir + // would take it too. + if (isSelfMode(partition.config) && (await holdsSelfKnowledge(legacyDir))) { + return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'superseded', previousOwner: legacyOwner }; + } + return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'retire-only', legacyOwner }; } // A partition config that detection cannot read is not "built": the legacy dir // holds the only config that still loads. Leave it in place; once the member @@ -190,7 +286,7 @@ export async function planMigration(cwd?: string): Promise return null; } - return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'full' }; + return { legacyDir, partitionDir, anchor: anchors.projectAnchor, mode: 'full', legacyOwner }; } /** Tell the member why the legacy dir stays and what lets the next run migrate it (#797). */ @@ -211,7 +307,7 @@ function warnKeptLegacy(legacyDir: string, partitionDir: string, partition: Part } type PartitionState = - | { state: 'built' } + | { state: 'built'; config: LocalConfig } | { state: 'unreadable'; configPath: string; error: string } | { state: 'absent' }; @@ -225,7 +321,7 @@ async function readPartitionState(partitionDir: string, workspaceRoot: string): const config = await readConfigFrom(partitionDir, workspaceRoot, undefined, (configPath, error) => { unreadable = { state: 'unreadable', configPath, error }; }); - if (config) return { state: 'built' }; + if (config) return { state: 'built', config }; return unreadable ?? { state: 'absent' }; } @@ -237,6 +333,13 @@ async function anyExists(dir: string, names: readonly string[]): Promise { +): Promise { const { legacyDir, partitionDir, anchor, mode } = plan; if (opts.dryRun) { - if (mode === 'self') { + if (plan.mode === 'superseded') { + log.info( + `[dry-run] would move what the previous ${plan.previousOwner.kind} install left in ${legacyDir} ` + + `to a ${legacyDir}.bak backup and set its queue aside, leaving team knowledge in place.`, + ); + } else if (mode === 'self') { const present = []; - for (const n of SELF_A1_ENTRIES) { + for (const n of [SELF_LEGACY_QUEUE, ...SELF_A1_ENTRIES]) { if (await pathExists(path.join(legacyDir, n))) present.push(n); } + const stale = []; + for (const n of SELF_STALE_ENTRIES) { + if (await pathExists(path.join(legacyDir, n))) stale.push(n); + } log.info( `[dry-run] self mode: would relocate ${present.length} machine-data item(s) ` + - `(${present.join(', ')}) from ${legacyDir} to ${partitionDir}, leaving team ` + - `knowledge in place.`, + `(${present.join(', ')}) from ${legacyDir} to ${partitionDir}` + + (stale.length > 0 ? ` and delete ${stale.join(', ')}` : '') + + ', leaving team knowledge in place.', ); } else if (mode === 'retire-only') { log.info( @@ -289,7 +404,17 @@ export async function runMigration( const lockPath = path.join(legacyDir, SYNC_LOCK_FILENAME); if (!(await acquireLock(lockPath))) { log.debug('migration skipped: a concurrent pull/push holds the sync lock'); - return 'skipped'; + return 'busy'; + } + // A command that loaded the config in legacyDir and queues a learning while + // this moves it would leave that learning behind (#823 item 11). The queue + // lock lives outside legacyDir, so it is held until the dir or its config has + // moved; the writer then finds its config gone and saves nothing. + const queueLock = await acquireQueueLock(legacyDir); + if (!queueLock.acquired) { + await releaseLock(lockPath); + log.debug('migration skipped: a queue write holds the queue lock'); + return 'busy'; } const staging = `${partitionDir}.staging`; @@ -299,15 +424,18 @@ export async function runMigration( // `.teamai/` into the partition, leaving class-B knowledge and the // reports-wt/knowledge-wt worktrees untouched. The `.teamai/` dir is NEVER // renamed — the knowledge on main must stay exactly where it is. - if (mode === 'self') { - await migrateSelfA1(legacyDir, partitionDir); - return 'migrated'; + if (plan.mode === 'self') { + return (await migrateSelfA1(legacyDir, partitionDir, plan.legacyOwner)) ? 'migrated' : 'skipped'; + } + if (plan.mode === 'superseded') { + return (await retireSupersededInstall(legacyDir, partitionDir, plan.previousOwner)) ? 'migrated' : 'skipped'; } // 'retire-only': a prior run already built the partition but crashed before // retiring the source. The partition is authoritative — do NOT re-copy onto // it — just finish by retiring the leftover legacy dir. - if (mode === 'retire-only') { + if (plan.mode === 'retire-only') { + if (!(await queueSettled(legacyDir, partitionDir, plan.legacyOwner))) return 'skipped'; await releaseLock(lockPath); lockReleased = true; const backup = await retireLegacy(legacyDir); @@ -321,6 +449,7 @@ export async function runMigration( // other partition dir is there, keep the legacy dir as planMigration does. const partition = await readPartitionState(partitionDir, path.dirname(legacyDir)); if (partition.state === 'built') { + if (!(await queueSettled(legacyDir, partitionDir, plan.legacyOwner))) return 'skipped'; await releaseLock(lockPath); lockReleased = true; const backup = await retireLegacy(legacyDir); @@ -387,6 +516,7 @@ export async function runMigration( throw e; } finally { if (!lockReleased) await releaseLock(lockPath); + await releaseLock(queueLock.lockPath); } } @@ -395,10 +525,106 @@ export async function runMigration( * "nothing to do" case. Migration failures are surfaced (a write command should * not silently proceed on stale legacy data), but never crash a dry-run preview. */ -export async function maybeMigrate(opts: { dryRun?: boolean } = {}): Promise { +export async function maybeMigrate(opts: { dryRun?: boolean } = {}): Promise { const plan = await planMigration(); - if (!plan) return; - await runMigration(plan, opts); + if (plan) return runMigration(plan, opts); + if (!opts.dryRun) await settleOrphanQueue(); + return undefined; +} + +/** + * A queue in a checkout's `.teamai/` with no config beside it is one an older + * self install kept there, its config already in the partition. Once `init` + * switched the project to another install no plan covers it, so settle it here, + * or nothing ever would (#808). + */ +async function settleOrphanQueue(): Promise { + const anchors = await resolveAnchors(process.cwd()); + if (!anchors) return; + const legacyDir = path.join(anchors.workspaceRoot, '.teamai'); + if (!(await holdsQueued(path.join(legacyDir, SELF_LEGACY_QUEUE)))) return; + if (await pathExists(path.join(legacyDir, 'config.yaml'))) return; + const partitionDir = await resolvePartitionDir(anchors.projectAnchor); + const partition = await readPartitionState(partitionDir, anchors.workspaceRoot); + if (partition.state !== 'built') return; + const remote = (await getRemoteUrl(anchors.workspaceRoot)) ?? ''; + await settleCheckoutQueue(legacyDir, partitionDir, { kind: 'self', remote }); +} + +/** + * Why a learning queued now would be kept in this checkout's `.teamai/`, which + * a removed linked worktree takes with it (#808), as the message a command that + * queues one stops with; null when it would not be. Asked after the migration: + * its data home is still that directory, or an old queue there could not move. + * Outside a git repo `.teamai/` is where the data belongs. + */ +export async function queueKeptInCheckout(migration: MigrationResult | undefined): Promise { + switch (migration) { + case 'dry-run': + return null; + case 'busy': + return keptInCheckout('another teamai command is using it', 'Run this again when that command finishes.'); + case 'migrated': + case 'skipped': + case undefined: + break; + default: { + const unhandled: never = migration; + throw new Error(`Unhandled migration result: ${String(unhandled)}`); + } + } + const anchors = await resolveAnchors(process.cwd()); + if (!anchors) return null; + const legacyDir = path.join(anchors.workspaceRoot, '.teamai'); + // A home that is itself a git repo: its `.teamai/` is the user scope's data + // home, and the queue there is the user scope's. + const home = getUserHome(); + if (anchors.workspaceRoot === (await realpath(home).catch(() => home))) return null; + const config = await detectProjectConfig(anchors.workspaceRoot); + const queueDir = config ? pendingLearningsDir(config) : null; + if (queueDir && isInside(queueDir, legacyDir)) { + const partitionDir = await resolvePartitionDir(anchors.projectAnchor); + const partition = await readPartitionState(partitionDir, anchors.workspaceRoot); + switch (partition.state) { + case 'unreadable': + return keptInCheckout( + `${partition.configPath} cannot be read: ${partition.error.split('\n')[0]}`, + 'Fix that file, then run this again.', + ); + case 'absent': + return keptInCheckout( + `${partitionDir} has no config.yaml`, + `Restore that file, or move ${partitionDir} aside, then run this again.`, + ); + case 'built': + return keptInCheckout( + `its queue, ${queueDir}, is inside this checkout`, + `Set repo.localPath in ${path.join(partitionDir, 'config.yaml')} to a path outside it, then run this again.`, + ); + default: { + const unhandled: never = partition; + throw new Error(`Unhandled partition state: ${JSON.stringify(unhandled)}`); + } + } + } + const queue = path.join(legacyDir, SELF_LEGACY_QUEUE); + if (await holdsQueued(queue)) { + return keptInCheckout( + `the learnings queued in ${queue} could not be moved; see the warning above`, + 'Do what it says, then run this again.', + ); + } + return null; +} + +function keptInCheckout(cause: string, next: string): string { + return `teamai could not move this checkout's data into the project's shared data directory (${cause}). Nothing was saved. ${next}`; +} + +/** True when `p` is `dir` or inside it. */ +function isInside(p: string, dir: string): boolean { + const rel = path.relative(dir, p); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); } /** @@ -491,16 +717,66 @@ async function retireLegacy(legacyDir: string): Promise { // Make the backup ignore everything it contains, independent of repo rules and // the backup's eventual name. Written before the rename so there is never a // window in which the credentials sit in a non-ignored directory. - await writeFile(path.join(legacyDir, '.gitignore'), '# teamai migration backup — ignore everything\n*\n'); + await writeFile(path.join(legacyDir, '.gitignore'), BACKUP_GITIGNORE); + const backup = await freeBackupPath(legacyDir); + await fse.rename(legacyDir, backup); + return backup; +} + +const BACKUP_GITIGNORE = '# teamai migration backup — ignore everything\n*\n'; + +/** The first free `.bak` name beside `legacyDir`: an existing backup is never overwritten. */ +async function freeBackupPath(legacyDir: string): Promise { let backup = `${legacyDir}.bak`; for (let n = 1; await pathExists(backup); n++) { backup = `${legacyDir}.bak.${n}`; } - await fse.rename(legacyDir, backup); return backup; } +/** + * Take a superseded install out of a checkout's `.teamai/` without touching + * the knowledge beside it (#808): its queue is set aside, since the self + * install would publish it to its own repository, and its machine data moves + * to a new git-ignored `.bak` backup, as retireLegacy keeps a whole dir. + * False when learnings are still queued once the rest has moved (an older + * `contribute` may queue one meanwhile): `config.yaml`, which tells the next + * run whose queue it is, stays with them, so a command that queues stops on it + * (queueKeptInCheckout) and the next run tries again. + */ +async function retireSupersededInstall(legacyDir: string, partitionDir: string, previousOwner: QueueOwner): Promise { + const present: string[] = []; + for (const name of SUPERSEDED_ENTRIES) { + const src = path.join(legacyDir, name); + if (!(await pathExists(src))) continue; + if (name === 'env' && (await isDirectory(src))) continue; + present.push(name); + } + const backup = await freeBackupPath(legacyDir); + if (present.length > 0) { + await fse.ensureDir(backup); + await writeFile(path.join(backup, '.gitignore'), BACKUP_GITIGNORE); + } + const move = (name: string) => fse.move(path.join(legacyDir, name), path.join(backup, name)); + for (const name of present) if (name !== 'config.yaml') await move(name); + if (!(await queueSettled(legacyDir, partitionDir, previousOwner))) { + log.warn( + `Kept ${path.join(legacyDir, 'config.yaml')} beside the learnings still queued in ` + + `${path.join(legacyDir, SELF_LEGACY_QUEUE)}: it tells the next run which install queued them. ` + + 'The next init, pull or push tries again.', + ); + return false; + } + if (present.includes('config.yaml')) await move('config.yaml'); + if (present.length === 0) return true; + log.success( + `Moved what the previous ${previousOwner.kind} install left in ${legacyDir} (${present.join(', ')}) ` + + `to ${backup}; the team knowledge stays in place. Remove the backup once you no longer need it.`, + ); + return true; +} + /** * True when `dir` (a `.teamai/`) carries single-repo team knowledge — a * `teamai.yaml` with `mode: self`. Such a directory is committed to main and must @@ -536,9 +812,17 @@ async function holdsSelfKnowledge(dir: string): Promise { * `/env.local` while we relocate: the atomic rename guarantees it sees * either the complete file or nothing, never a half-written one. Uses raw fse.copy * (NOT copyDir) so a nested `.git` under workspaces/ survives. + * + * False, relocating nothing, when learnings are still queued in the checkout + * once its queue was settled (the queue lock was busy, the partition config + * cannot be read, or an older `contribute` queued one meanwhile): its + * `config.yaml` tells the next run whose queue it is, so a command that queues + * stops on it (queueKeptInCheckout) and the next run tries again. */ -async function migrateSelfA1(legacyDir: string, partitionDir: string): Promise { +async function migrateSelfA1(legacyDir: string, partitionDir: string, legacyOwner: QueueOwner): Promise { await fse.ensureDir(partitionDir); + if (!(await queueSettled(legacyDir, partitionDir, legacyOwner))) return false; + for (const name of SELF_STALE_ENTRIES) await remove(path.join(legacyDir, name)); const moved: string[] = []; for (const name of SELF_A1_ENTRIES) { const src = path.join(legacyDir, name); @@ -582,6 +866,134 @@ async function migrateSelfA1(legacyDir: string, partitionDir: string): Promise { + const queue = path.join(legacyDir, SELF_LEGACY_QUEUE); + if (!(await pathExists(queue))) return; + // The install read below decides where the queue goes; an init switching it + // meanwhile would publish what lands in its live queue (#823 item 11). + const settled = await withQueueLock(partitionDir, () => settleByPartitionOwner(legacyDir, partitionDir, owner)); + if (settled.status === 'busy') { + log.warn( + `Kept the learnings queued in ${queue}: another teamai command holds ${settled.lockPath}. ` + + 'The next init, pull or push moves them.', + ); + } +} + +async function settleByPartitionOwner(legacyDir: string, partitionDir: string, owner: QueueOwner): Promise { + const queue = path.join(legacyDir, SELF_LEGACY_QUEUE); + const partition = await readPartitionState(partitionDir, path.dirname(legacyDir)); + switch (partition.state) { + case 'absent': + await drainLegacyQueue(legacyDir, partitionDir); + return; + case 'built': + if (sameQueueOwner(queueOwner(partition.config), owner)) { + await drainLegacyQueue(legacyDir, partitionDir); + } else { + await setAsideCheckoutQueue(queue, partition.config, owner); + } + return; + case 'unreadable': + log.warn( + `Kept the learnings queued in ${queue}: ${partition.configPath} cannot be read, so teamai cannot tell ` + + 'which repository they would be published to. Fix that file; the next init, pull or push moves them.', + ); + return; + default: { + const unhandled: never = partition; + throw new Error(`Unhandled partition state: ${JSON.stringify(unhandled)}`); + } + } +} + +/** + * Settle the queue in a legacy dir about to be retired to `.teamai.bak`, which + * a removed linked worktree takes with it (#808). False when some of it could + * not move: the dir is kept, so a command that queues stops on it + * (queueKeptInCheckout) and the next run tries again. + */ +async function queueSettled(legacyDir: string, partitionDir: string, legacyOwner: QueueOwner): Promise { + await settleCheckoutQueue(legacyDir, partitionDir, legacyOwner); + return !(await holdsQueued(path.join(legacyDir, SELF_LEGACY_QUEUE))); +} + +/** True when `queue` holds a learning, as the set-aside counts them: another file is not one. */ +async function holdsQueued(queue: string): Promise { + return (await countQueued(queue)) > 0; +} + +/** + * Move a checkout's queued learnings into the partition queue, one file at a + * time, each via a temp sibling and an atomic rename, then delete the source. + * The partition queue is shared by every checkout, so a file there is never + * overwritten: the same content means this one already moved, and different + * content is kept in place, with a warning, for the member to resolve (queue + * names carry a random suffix, so only a hand edit gets there). Once nothing + * is kept, the old queue directory goes. + */ +async function drainLegacyQueue(legacyDir: string, partitionDir: string): Promise { + const src = path.join(legacyDir, SELF_LEGACY_QUEUE); + if (!(await pathExists(src))) return; + const dest = path.join(partitionDir, SELF_LEGACY_QUEUE); + const kept: string[] = []; + let moved = 0; + // In the partition queue now, moved by this run or by one that stopped + // before it dropped the indexes. + let placed = 0; + for (const relPath of await listFilesRecursive(src)) { + const from = path.join(src, relPath); + const to = path.join(dest, relPath); + // Gone since the listing: a concurrent drain (contribute beside a pull) + // already moved it. What is written below is what was read here. + const content = await readFileIfExists(from); + if (content === null) continue; + const existing = await readFileIfExists(to); + if (existing === null) { + const tmp = `${to}.${process.pid}.tmp`; + await remove(tmp); + await fse.ensureDir(path.dirname(to)); + await fse.writeFile(tmp, content); + await fse.rename(tmp, to); + moved++; + } else if (existing !== content) { + kept.push(from); + continue; + } + placed++; + await remove(from); + } + if (placed > 0) { + // No checkout's index may have them yet; recall rebuilds a missing one. + const { dropCheckoutIndexes } = await import('./utils/search-index.js'); + await dropCheckoutIndexes(partitionDir); + } + if (moved > 0) log.success(`Moved ${moved} queued learning(s) from ${src} to ${dest}.`); + if (kept.length > 0) { + log.warn( + `Kept ${kept.length} queued learning(s) in ${src}: ${dest} already has a different file ` + + `under the same name (${kept.map((f) => path.relative(src, f)).join(', ')}). Compare the two, ` + + `delete the one you do not want, and the next init, pull or push moves what is left.`, + ); + return; + } + // A contribute may have queued here since the listing; leave that for the next run. + if ((await listFilesRecursive(src)).length === 0) await remove(src); } /** True when `p` is a directory (following symlinks). Missing path → false. */ diff --git a/src/projects-cmd.ts b/src/projects-cmd.ts index e3c910129..443401668 100644 --- a/src/projects-cmd.ts +++ b/src/projects-cmd.ts @@ -145,10 +145,19 @@ export async function projectsMembers( let membersRoot = knowledgePath; const { usesBranchWorktree } = await import('./types.js'); if (usesBranchWorktree(localConfig)) { - const { ensureReportsWorktree, refreshReportsWorktree } = await import('./utils/reports-branch.js'); + const { readableReportsWorktree } = await import('./utils/reports-branch.js'); + const { CheckoutRefusedError } = await import('./utils/branch-worktree.js'); // Read-only: never publish a missing reports branch. - await refreshReportsWorktree(localConfig, { pushIfCreated: false }); - membersRoot = await ensureReportsWorktree(localConfig, { pushIfCreated: false }); + try { + membersRoot = await readableReportsWorktree(localConfig); + } catch (e) { + // A reports checkout teamai refused (#808): another repository's, whose + // roster is not this team's, or an old one in the way. The refusal, with + // the way out, was already printed. + if (!(e instanceof CheckoutRefusedError)) throw e; + process.exitCode = 1; + return; + } } else { await pullRepo(knowledgePath).catch(() => { /* offline — read local copy */ }); } diff --git a/src/pull.ts b/src/pull.ts index 9beddac85..33247a0de 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -10,7 +10,7 @@ import { requireInit, loadState, saveState, detectProjectConfig, describeUnreada import { pullRepo, getHeadRev, createGit, getDefaultBranch, listWorktrees } from './utils/git.js'; import { publishQueuedLearnings } from './utils/learnings-publish.js'; import { pendingLearningsDir } from './utils/pending-learnings.js'; -import { learningsRoots } from './utils/learnings-roots.js'; +import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; @@ -36,6 +36,7 @@ import { resolveToolBaseDir, resolveHookScope, getDataHome, + getProjectSearchIndexPath, isRecallEnabled, isAgentExcluded, scopedToolPaths, @@ -733,9 +734,8 @@ async function pullForScope( reportsReadRoot ??= (async () => { if (!usesBranchWorktree(localConfig)) return localConfig.repo.localPath; try { - const { ensureReportsWorktree, refreshReportsWorktree } = await import('./utils/reports-branch.js'); - await refreshReportsWorktree(localConfig, { pushIfCreated: false }); - return await ensureReportsWorktree(localConfig, { pushIfCreated: false }); + const { readableReportsWorktree } = await import('./utils/reports-branch.js'); + return await readableReportsWorktree(localConfig); } catch (e) { log.debug(`reports worktree unavailable: ${(e as Error).message}`); return undefined; @@ -775,13 +775,17 @@ async function pullForScope( // materializes a local view and never publishes the branch. try { const { learningsBranch } = await import('./utils/learnings-branch.js'); - await learningsBranch.refresh(localConfig, { pushIfCreated: false }); + const refreshed = await learningsBranch.refresh(localConfig, { pushIfCreated: false }); + // Busy or failed: index what is there. + if (refreshed.status === 'failed') log.debug(`learnings worktree unavailable: ${refreshed.reason}`); } catch (e) { log.debug(`learnings worktree unavailable: ${(e as Error).message}`); } - const roots = learningsRoots(localConfig); - const publishedRoots = roots.read; + // Without another repository's learnings checkout, if one sits where + // this project's would (#808): the refusal was warned, and everything + // else this project has stays indexed. + const publishedRoots = await indexableLearningsRoots(localConfig); const docsRepoDir = path.join(localConfig.repo.localPath, 'docs'); const rulesRepoDir = path.join(localConfig.repo.localPath, 'rules'); const skillsRepoDir = path.join(localConfig.repo.localPath, 'skills'); @@ -854,9 +858,9 @@ async function pullForScope( if (hasAnySource || effectiveCodebaseDir) { const votesExist = votesDir ? await pathExists(votesDir) : false; - const teamaiHome = getDataHome(localConfig); - const indexPath = path.join(teamaiHome, 'search-index.json'); - const { buildIndex } = await import('./utils/search-index.js'); + const indexPath = getProjectSearchIndexPath(localConfig); + const { buildIndex, dropOtherCheckoutIndexes } = await import('./utils/search-index.js'); + await dropOtherCheckoutIndexes(localConfig); const elapsed = await buildIndex({ // The queue comes first: a contribution that could not be published // yet stays recallable, and a queued edit wins over the published copy. @@ -1340,6 +1344,20 @@ async function pullForScope( } } + // Every checkout of the repo keeps `workspaces//` in the shared data home + // (search index, managed MCP, resource cache), and a removed worktree's stays + // behind. A full sync drops those; the fast path never lists worktrees (#808). + if (localConfig.scope === 'project' && localConfig.projectRoot && !options.dryRun) { + try { + const { listWorktrees } = await import('./utils/git.js'); + const { pruneWorkspaceDirs } = await import('./utils/partition.js'); + const removed = await pruneWorkspaceDirs(getDataHome(localConfig), await listWorktrees(localConfig.projectRoot)); + if (removed.length > 0) log.debug(`[${scopeLabel}] removed ${removed.length} directory(ies) of removed worktrees`); + } catch (e) { + log.debug(`[${scopeLabel}] workspace prune skipped: ${e instanceof Error ? e.message : String(e)}`); + } + } + // A real sync ran to completion for this scope. The "Already synced" fast path // and every error/skip path return before here, and dry-run is excluded so a // preview never reports completion (#702 follow-up). diff --git a/src/recall.ts b/src/recall.ts index 13edc9f9e..da5338477 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -6,7 +6,7 @@ import type { SearchResult } from './utils/search-index.js'; import { readFileSafe, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { GlobalOptions, SearchIndex, LocalConfig } from './types.js'; -import { getDataHome, getTeamaiHome, getVotesDir } from './types.js'; +import { getProjectSearchIndexPath, getUserSearchIndexPath, getVotesDir } from './types.js'; import { queryCodeKnowledge } from './code-knowledge-recall.js'; import type { CodeKnowledgeResult, SourceAnchor } from './code-knowledge-recall.js'; import { recordRecallQuality } from './recall-quality.js'; @@ -272,7 +272,7 @@ export async function autoUpvote( * Load or build a search index for a given scope config. * * - user scope: learnings 在 pull 时同步到 ~/.teamai/learnings/,索引存 ~/.teamai/search-index.json - * - project scope: learnings 只存在于 git repo 中(pull 不同步),索引存 /.teamai/search-index.json + * - project scope: learnings live only in the git repo (pull does not mirror them); the index is at getProjectSearchIndexPath * * 返回索引和 learnings 文件的实际基础路径(供 formatResults 输出正确的 File: 路径)。 */ @@ -280,16 +280,15 @@ async function loadOrBuildScopeIndex( localConfig: LocalConfig, scopeLabel: 'user' | 'project', ): Promise<{ index: SearchIndex; learningsBase: string } | null> { - // Route the project branch through getDataHome (so P1-2's partition redirect - // applies), but preserve the historical fallback to ~/.teamai when a project - // scope config lacks projectRoot: getDataHome → getTeamaiHome throws in that + // Route the project branch through getProjectSearchIndexPath (partition-aware, + // per checkout in self mode), but preserve the historical fallback to ~/.teamai + // when a project scope config lacks projectRoot: getDataHome → getTeamaiHome throws in that // case, and here the exception surfaces as a misleading "No learnings // available". A ~/.teamai/config.yaml with scope:project but no projectRoot is // permitted by LocalConfigSchema and not backfilled by loadLocalConfig. - const teamaiHome = localConfig.scope === 'project' && localConfig.projectRoot - ? getDataHome(localConfig) - : getTeamaiHome('user'); - const indexPath = path.join(teamaiHome, 'search-index.json'); + const indexPath = localConfig.scope === 'project' && localConfig.projectRoot + ? getProjectSearchIndexPath(localConfig) + : getUserSearchIndexPath(); // Learnings come from several roots: what is queued but not published yet, // what is on the learnings branch, the machine-local mirror, and the corpus @@ -319,9 +318,10 @@ async function loadOrBuildScopeIndex( // If it isn't materialized yet, votesExist is false and vote-weighted // ranking is simply skipped (graceful degradation — leftover default-branch // votes/ are not used). - const { getReportsDir } = await import('./types.js'); - const votesDir = path.join(getReportsDir(localConfig), 'votes'); - const votesExist = await pathExists(votesDir); + // Not another repository's reports checkout (#808). + const { indexableVotesDir } = await import('./utils/reports-branch.js'); + const votesDir = await indexableVotesDir(localConfig); + const votesExist = votesDir !== undefined && await pathExists(votesDir); const docsDir = path.join(localConfig.repo.localPath, 'docs'); const rulesDir = path.join(localConfig.repo.localPath, 'rules'); const repoCodebaseDir = path.join(localConfig.repo.localPath, 'docs', 'team-codebase'); @@ -339,8 +339,12 @@ async function loadOrBuildScopeIndex( try { const { deliveredIndexSources } = await import('./resources/desired.js'); + // Without another repository's learnings checkout, if one sits where + // this project's would (#808). The probe runs only here, when an index + // is built, never on a plain recall. + const { indexableLearningsRoots } = await import('./utils/learnings-roots.js'); await buildIndex({ - learningsDirs: indexLearningsDirs, + learningsDirs: [pendingLearningsDir(localConfig), ...await indexableLearningsRoots(localConfig)], learningsNamespaces, docsDir: await pathExists(docsDir) ? docsDir : undefined, rulesDir: await pathExists(rulesDir) ? rulesDir : undefined, diff --git a/src/stats.ts b/src/stats.ts index 8e39969c6..f427fd8f0 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -57,8 +57,8 @@ async function loadReportedStats(): Promise { let statsRoot = config.repo.localPath; const { usesBranchWorktree } = await import('./types.js'); if (usesBranchWorktree(config)) { - const { ensureReportsWorktree } = await import('./utils/reports-branch.js'); - statsRoot = await ensureReportsWorktree(config, { pushIfCreated: false }); + const { readableReportsWorktree } = await import('./utils/reports-branch.js'); + statsRoot = await readableReportsWorktree(config); } const statsPath = path.join(statsRoot, 'stats', `${config.username}.yaml`); const content = await readFileSafe(statsPath); diff --git a/src/types.ts b/src/types.ts index 18a77f552..b6cae87f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2133,9 +2133,9 @@ export const WORKTREE_DIRNAMES: readonly string[] = [ LEARNINGS_WORKTREE_DIRNAME, KNOWLEDGE_WORKTREE_DIRNAME, ]; -/** Lock filename (under /.teamai) guarding concurrent reports-branch writes. */ +/** Lock filename (beside the reports-wt checkout) guarding concurrent reports-branch writes. */ export const REPORTS_LOCK_FILENAME = '.reports-lock'; -/** Lock filename (under /.teamai) guarding concurrent learnings-branch writes. */ +/** Lock filename (beside the learnings-wt checkout) guarding concurrent learnings-branch writes. */ export const LEARNINGS_LOCK_FILENAME = '.learnings-lock'; /** Lock filename (under /.teamai) guarding concurrent self-mode bootstrap. */ export const BOOTSTRAP_LOCK_FILENAME = '.bootstrap-lock'; @@ -2184,10 +2184,24 @@ export function getDataHome(localConfig: LocalConfig): string { return getTeamaiHome(localConfig.scope, localConfig.projectRoot); } +/** + * The project-scope search index. Every checkout of a repo shares one data + * home, but in self mode each checkout indexes its own branch's docs, rules + * and skills, so the index is kept per checkout, the way managed MCP is + * (#808). Elsewhere the knowledge is one clone, and so is the index. + */ +export function getProjectSearchIndexPath(localConfig: LocalConfig): string { + const dataHome = getDataHome(localConfig); + if (isSelfMode(localConfig)) { + return path.join(dataHome, 'workspaces', managedMcpWorkspaceId(getBusinessRoot(localConfig)), 'search-index.json'); + } + return path.join(dataHome, 'search-index.json'); +} + /** * Directory holding reports data (members/sessions/votes/stats). * - http: same as knowledge (localPath) — HTTP does not use the git reports branch. - * - self: /reports-wt — nested under the knowledge dir (`.teamai/`). + * - self: /reports-wt — in the partition, shared by every checkout. * - git (and legacy configs with no kind): sibling of the clone * (`/reports-wt`) so clone `reset --hard` cannot nest-destroy it. * Callers must ensure the worktree exists first (see ensureReportsWorktree) @@ -2200,7 +2214,9 @@ export function getReportsDir(localConfig: LocalConfig): string { /** * Where a side-branch worktree lives for this repo. * - http: the knowledge dir itself — HTTP has no git branch to check out. - * - self: / — nested under the knowledge dir (`.teamai/`). + * - self: / — in the partition, not in the checkout's + * `.teamai/`: git checks a branch out in one worktree only, so every + * checkout of the business repo shares this one (#808). * - git (and legacy configs with no kind): sibling of the clone * (`/`) so clone `reset --hard` cannot * nest-destroy it. @@ -2212,7 +2228,7 @@ export function getWorktreeDir(localConfig: LocalConfig, dirname: string): strin return localConfig.repo.localPath; } if (isSelfMode(localConfig)) { - return path.join(localConfig.repo.localPath, dirname); + return path.join(getDataHome(localConfig), dirname); } return path.join(path.dirname(localConfig.repo.localPath), dirname); } diff --git a/src/uninstall.ts b/src/uninstall.ts index c5dfcdc64..f6fc522b9 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -62,6 +62,7 @@ import { listFilesRecursive, expandHome, } from './utils/fs.js'; +import { listQueuesIn } from './utils/pending-learnings.js'; import { log } from './utils/logger.js'; import { askConfirmation } from './utils/prompt.js'; import { getUserHome } from './utils/home.js'; @@ -116,6 +117,11 @@ interface RemovalPlan { teamaiHome: string; /** Whether teamaiHome exists on disk. */ teamaiHomeExists: boolean; + /** + * Queues of learnings not published yet that deleting teamaiHome takes with + * it, each with how many it holds; empty when teamaiHome stays. + */ + unpublishedQueues: Array<{ dir: string; count: number }>; /** Whether shared resources (docs / ~/.teamai / shell profile) are part of this removal. */ includeShared: boolean; /** Whether this removal targets Hermes (clears its SOUL.md block + config.yaml hook). */ @@ -615,6 +621,7 @@ async function buildRemovalPlan( docsDir: null, teamaiHome, teamaiHomeExists: includeShared && await pathExists(teamaiHome), + unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], includeShared, hermesCleanup: toolsToMerge.includes('hermes'), scope: localConfig.scope, @@ -848,6 +855,15 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(` ${plan.teamaiHome}/`); console.log(''); } + + if (plan.unpublishedQueues.length > 0) { + console.log('⚠ Learnings not published yet, deleted with the home directory:'); + for (const { dir, count } of plan.unpublishedQueues) { + console.log(` ${count} unpublished learning(s) in ${dir}`); + } + console.log(' Run `teamai pull` to publish them first, or copy them somewhere safe.'); + console.log(''); + } } // ─── Execution ───────────────────────────────────────── diff --git a/src/utils/branch-worktree.ts b/src/utils/branch-worktree.ts index c67e8ad7f..8d9d45ad4 100644 --- a/src/utils/branch-worktree.ts +++ b/src/utils/branch-worktree.ts @@ -7,7 +7,8 @@ * names is the whole difference. * * Worktree placement: - * - self: /.teamai/ + * - self: /, in the partition, so every checkout of the + * business repo shares it (#808) * - git / legacy: sibling of the clone (`/`) so * clone `reset --hard` cannot nest-destroy it * @@ -30,7 +31,7 @@ import type { SimpleGit } from 'simple-git'; import { createGit, isGitRepo, commitSkippingHooks, isDedicatedRepoRoot } from './git.js'; import { acquireLock, releaseLock } from '../update.js'; import { ensureDir, writeFile, pathExists } from './fs.js'; -import { log } from './logger.js'; +import { isSilent, log } from './logger.js'; import { WORKTREE_DIRNAMES, getBusinessRoot, @@ -64,7 +65,7 @@ export type PublishResult = | { status: 'published' } | { status: 'already-present' } | { status: 'busy' } - | { status: 'failed'; reason: string }; + | { status: 'failed'; reason: string; refused?: true }; /** True when the publish landed, i.e. the caller may drop its durable copy. */ export function isPublished(result: PublishResult): boolean { @@ -141,7 +142,7 @@ async function fetchTrackingRef(git: SimpleGit, branch: string): Promise { /** * Ensure a git worktree checked out on this branch exists. - * Self: /. Independent git: sibling of the clone. + * Self: /. Independent git: sibling of the clone. * Idempotent. Returns the worktree absolute path. * * Cold-start cases handled: @@ -179,17 +180,23 @@ async function ensureWorktree( } // Already a valid worktree — nothing to do. `isGitRepo` only checks that a - // `.git` file/dir exists; after a sibling clone is deleted and re-cloned the - // worktree gitdir (`/.git/worktrees/`) is gone and git ops - // fail with "not a git repository". Probe a real git command and fall through - // to remove+recreate when the link is stale. + // `.git` file/dir exists, so probe a real git command. A checkout git cannot + // open is removed and recreated only while this repo still registers it; + // after the clone is deleted and cloned again its registration + // (`/.git/worktrees/`) is gone, and it is refused and kept. if (await isGitRepo(wt)) { + let valid = false; try { await createGit(wt).revparse(['--is-inside-work-tree']); - return wt; + valid = true; } catch { - // stale/dangling worktree link (clone was re-cloned/pruned) — recreate below. + // stale/dangling worktree link — recreated below if this repo registers it. } + if (valid) { + await refuseForeignCheckout(spec, wt, repoRoot); + return wt; + } + await refuseUnprovenCheckout(spec, wt, repoRoot); } // Path exists but is not a git worktree (stale/partial) — clear it so we can recreate. @@ -206,6 +213,7 @@ async function ensureWorktree( } catch { // best effort } + if (isSelfMode(localConfig)) await removeOldCheckoutsInDotTeamai(spec, git, wt); if (await remoteBranchExists(spec, repoRoot)) { // Remote branch exists: fetch and check it out into the worktree. @@ -256,6 +264,199 @@ async function ensureWorktree( return wt; } +/** + * Self mode used to check this branch out in each checkout's own `.teamai/` + * (#808). Git checks a branch out in one worktree only, so a checkout left + * there by an older teamai blocks the shared one at `wt`, from every checkout + * of the repo. Remove it the way a member would: without --force, so a checkout + * with uncommitted changes stays, and the member is told what to do with them. + * Its commits are on the branch, which the new checkout reuses. + */ +async function removeOldCheckoutsInDotTeamai(spec: BranchWorktreeSpec, git: SimpleGit, wt: string): Promise { + const oldSuffix = `${path.sep}${path.join('.teamai', spec.worktreeDirname)}`; + const listing = await git.raw(['worktree', 'list', '--porcelain']); + for (const entry of listing.split('\n\n')) { + const lines = entry.split('\n'); + const checkout = lines.find((l) => l.startsWith('worktree '))?.slice('worktree '.length); + const branch = lines.find((l) => l.startsWith('branch '))?.slice('branch '.length); + if (checkout === undefined || branch !== `refs/heads/${spec.branch}`) continue; + // resolve: git prints forward slashes on Windows too. + if (!path.resolve(checkout).endsWith(oldSuffix) || path.resolve(checkout) === path.resolve(wt)) continue; + try { + await git.raw(['worktree', 'remove', checkout]); + log.debug(`[${spec.logTag}] removed the old checkout at ${checkout}`); + } catch (e) { + const reason = (e instanceof Error ? e.message : String(e)).trim().split('\n')[0]; + refuse(new CheckoutRefusedError( + `${checkout} still has ${spec.branch} checked out, and git will not remove it (${reason}). ` + + `teamai now keeps that checkout at ${wt}, shared by every checkout of this repo. ` + + `Commit or move the uncommitted changes in ${checkout} (or unlock it, if git says it is locked), ` + + 'or delete it by hand, which loses those uncommitted changes, then run the command again.', + `an old ${spec.branch} checkout is in the way; see the warning above`, + )); + } + } +} + +/** + * Self and git mode keep this checkout at the same path in the partition + * (#808), so after a project switches mode, the checkout there may belong to + * the other repository: using it would publish to the wrong remote. Refuse it, + * and never remove it: it is the other install's. + */ +async function refuseForeignCheckout(spec: BranchWorktreeSpec, wt: string, repoRoot: string): Promise { + const [owner, expected] = await Promise.all([commonDir(wt), commonDir(repoRoot)]); + if (owner === expected) return; + refuseCheckoutOf(spec, wt, repoRoot, owner); +} + +/** + * A checkout git cannot open is this repo's only when its files lead to the + * repo's git dir through a live registration, which ensure recreates. Refuse + * any other, and never remove it: its `.git` may lead to a repository that was + * moved, deleted or cloned again (init reclones another team repo at the same + * path), so its uncommitted files may be another install's. + */ +async function refuseUnprovenCheckout(spec: BranchWorktreeSpec, wt: string, repoRoot: string): Promise { + const [owner, expected] = await Promise.all([commonDirFromFiles(wt), commonDirFromFiles(repoRoot)]); + if (owner !== null && owner === expected) return; + refuseCheckoutOf(spec, wt, repoRoot, expected === null ? null : owner); +} + +/** Refuse the checkout at `wt`: another repository's (`owner`), or one whose repository is unknown (null). */ +function refuseCheckoutOf(spec: BranchWorktreeSpec, wt: string, repoRoot: string, owner: string | null): never { + if (owner === null) { + refuse(new ForeignCheckoutError( + `${wt} is a ${spec.branch} checkout teamai cannot show to be ${repoRoot}'s: git cannot open it, ` + + 'and its .git does not lead to a registration in that repository (the repository it came from may ' + + 'have been moved, deleted, or cloned again). ' + + 'teamai will not use or remove it. Move it aside, or delete it if it holds nothing you need, ' + + 'then run the command again.', + `the ${spec.branch} checkout cannot be shown to belong to this repository; see the warning above`, + )); + } + const ownerRepo = path.basename(owner) === '.git' ? path.dirname(owner) : owner; + refuse(new ForeignCheckoutError( + `${wt} is a ${spec.branch} checkout of ${ownerRepo}, not of ${repoRoot}, left by an install in another mode. ` + + `teamai will not use or remove it. Remove it with \`git -C ${ownerRepo} worktree remove ${wt}\`, ` + + 'then run the command again.', + `the ${spec.branch} checkout belongs to another repository; see the warning above`, + )); +} + +/** + * A side-branch checkout teamai will not use until the member acts. `message` + * says what and how, and is warned once (see refuse); `summary` is the short + * reason a caller quotes, so the long one is not printed twice. + */ +export class CheckoutRefusedError extends Error { + override name = 'CheckoutRefusedError'; + constructor(message: string, readonly summary: string) { + super(message); + } +} + +/** The reason a failed side-branch write reports: short for a refusal that was warned, whole otherwise. */ +export function failureReason(e: unknown): string { + if (e instanceof CheckoutRefusedError && warnedRefusals.has(e.message)) return e.summary; + return e instanceof Error ? e.message : String(e); +} + +/** A failed write; `refused` when a checkout refusal stopped it, which every retry meets until the member acts. */ +function failedWith(e: unknown): PublishResult { + return { status: 'failed', reason: failureReason(e), refused: e instanceof CheckoutRefusedError || undefined }; +} + +/** + * The side-branch checkout belongs to another repository, or cannot be shown + * to belong to this one (see refuseCheckoutOf). Unlike other side-branch + * failures it is not best-effort: every path under the checkout may be another + * install's, so a caller must not read or write them. + */ +export class ForeignCheckoutError extends CheckoutRefusedError { + override name = 'ForeignCheckoutError'; +} + +/** + * Refuse the checkout when one exists and is not provably this repository's; + * no checkout, or a stale one this repository still registers, passes (ensure + * recreates it). + * For callers that read the checkout's paths without ensuring it, such as an + * index build. + */ +async function checkOwnerImpl(spec: BranchWorktreeSpec, localConfig: LocalConfig): Promise { + if (!usesBranchWorktree(localConfig)) return; + const wt = worktreePath(spec, localConfig); + if (!(await isGitRepo(wt))) return; + try { + await createGit(wt).revparse(['--is-inside-work-tree']); + } catch { + await refuseUnprovenCheckout(spec, wt, gitRoot(localConfig)); + return; + } + await refuseForeignCheckout(spec, wt, gitRoot(localConfig)); +} + +/** The repository's shared git directory, resolved: every worktree of one repo agrees on it. */ +async function commonDir(dir: string): Promise { + const out = (await createGit(dir).revparse(['--git-common-dir'])).trim(); + return fse.realpath(path.resolve(dir, out)); +} + +/** + * The same shared git directory, read from the files git writes instead of + * from a git process: a `.git` directory is it; a `.git` file names the + * worktree's gitdir, whose `commondir` leads to it. A linked worktree's gitdir + * (`/worktrees/`) without that file proves nothing: its + * registration is gone, and a repository cloned again at ``'s path is + * not the one that checkout came from. Null when it cannot be read that way. + */ +async function commonDirFromFiles(dir: string): Promise { + const dotGit = path.join(dir, '.git'); + try { + if ((await fse.stat(dotGit)).isDirectory()) return await fse.realpath(dotGit); + const pointer = /^gitdir:\s*(.+)$/m.exec(await fse.readFile(dotGit, 'utf-8')); + if (!pointer) return null; + const gitDir = path.resolve(dir, pointer[1].trim()); + const common = await fse.readFile(path.join(gitDir, 'commondir'), 'utf-8').catch(() => null); + if (common !== null) return await fse.realpath(path.resolve(gitDir, common.trim())); + if (path.basename(path.dirname(gitDir)) === 'worktrees') return null; + return await fse.realpath(gitDir); + } catch { + return null; + } +} + +/** + * Whether the checkout there is not provably this repository's, judged from + * git's files alone, for paths that must not start a git process (hooks). No + * checkout (no `.git`) is not judged foreign; one whose `.git`, or the repo's + * git dir, cannot be read is. + */ +async function isForeignByFilesImpl(spec: BranchWorktreeSpec, localConfig: LocalConfig): Promise { + if (!usesBranchWorktree(localConfig)) return false; + const wt = worktreePath(spec, localConfig); + if (!(await pathExists(path.join(wt, '.git')))) return false; + const [owner, expected] = await Promise.all([commonDirFromFiles(wt), commonDirFromFiles(gitRoot(localConfig))]); + return owner === null || owner !== expected; +} + +/** + * Fail with a message the member acts on. Callers that treat a side-branch + * failure as non-fatal only log it at debug, so it is warned here too, once + * per process however often a command retries. A silent run (a hook, the task + * list of `import --from-mr`) prints nothing, so its refusal is not counted as + * warned and failureReason gives the whole message. + */ +const warnedRefusals = new Set(); +function refuse(error: Error): never { + if (!isSilent() && !warnedRefusals.has(error.message)) { + warnedRefusals.add(error.message); + log.warn(error.message); + } + throw error; +} + /** * Create an orphan-branch worktree. Uses the modern `--orphan` flag (git 2.42+) * and falls back to the detach + `checkout --orphan` dance for older git. @@ -405,7 +606,7 @@ async function commitAndPushImpl( return await commitAndPushAt(spec, wt, message, files, options); } catch (e) { log.debug(`[${spec.logTag}] commitAndPush failed (non-blocking): ${(e as Error).message}`); - return { status: 'failed', reason: (e as Error).message }; + return failedWith(e); } finally { await releaseLock(lockPath); } @@ -449,7 +650,7 @@ async function updateImpl( return await commitAndPushAt(spec, wt, change.message, change.files, options); } catch (e) { log.debug(`[${spec.logTag}] update failed (non-blocking): ${(e as Error).message}`); - return { status: 'failed', reason: (e as Error).message }; + return failedWith(e); } finally { await releaseLock(lockPath); } @@ -614,38 +815,57 @@ async function syncWorktree(spec: BranchWorktreeSpec, wt: string): Promise /** * Best-effort refresh from origin so readers see other members' latest data. - * Read-only callers pass `pushIfCreated: false`. When a write holds the lock, - * the local copy is used as-is. Never throws. Only ever touches the - * orphan-branch worktree, never the active tree. + * Read-only callers pass `pushIfCreated: false`. When the lock cannot be taken + * (a write holds it, or it cannot be created), nothing is checked or touched + * and the result is busy: a reader uses the local copy as-is, and a missing one + * is left to that write. A caller that + * rewrites the checkout must stop instead: it may be another repository's, or + * still being created. Failed, with the cause, when the checkout could not be + * created or synced: a reader uses what is there, and a caller that rewrites + * it stops, as the checkout may not exist. Throws only CheckoutRefusedError, + * when teamai will not use the checkout until the member acts. Only ever + * touches the orphan-branch worktree, never the active tree. */ async function refreshImpl( spec: BranchWorktreeSpec, localConfig: LocalConfig, options: EnsureWorktreeOptions = {}, -): Promise { +): Promise { if (!usesBranchWorktree(localConfig)) { - return; + return { status: 'done' }; } const lockPath = lockFilePath(spec, localConfig); let locked = false; try { locked = await acquireLock(lockPath); - const wt = await ensureWorktree(spec, localConfig, options); if (!locked) { + // Every checkout of a self-mode repo shares this path (#808): the holder + // may be creating it right now. log.debug(`[${spec.logTag}] a write is in progress; reading the local copy`); - return; + return { status: 'busy', lockPath }; } + const wt = await ensureWorktree(spec, localConfig, options); await syncWorktree(spec, wt); } catch (e) { - log.debug(`[${spec.logTag}] refresh skipped: ${(e as Error).message}`); + // A refused checkout throws: the caller's paths are under it, or under the + // checkout it keeps from being created. + if (e instanceof CheckoutRefusedError) throw e; + return { status: 'failed', reason: failureReason(e).trim() }; } finally { if (locked) { await releaseLock(lockPath); } } + return { status: 'done' }; } +/** What a refresh did; busy names the lock it could not take, failed why the checkout is not ready. */ +export type RefreshResult = + | { status: 'done' } + | { status: 'busy'; lockPath: string } + | { status: 'failed'; reason: string }; + /** One branch's worth of behaviour, behind one interface. */ export interface BranchWorktree { readonly branch: string; @@ -665,7 +885,11 @@ export interface BranchWorktree { files: string[], options?: { pushIfUnchanged?: boolean }, ): Promise; - refresh(localConfig: LocalConfig, options?: EnsureWorktreeOptions): Promise; + refresh(localConfig: LocalConfig, options?: EnsureWorktreeOptions): Promise; + /** Throws ForeignCheckoutError when the checkout there is not provably this repository's; creates nothing. */ + checkOwner(localConfig: LocalConfig): Promise; + /** True when the checkout there is not provably this repository's; reads git's files, runs no git. */ + isForeignByFiles(localConfig: LocalConfig): Promise; } /** @@ -692,5 +916,7 @@ export function createBranchWorktree(spec: BranchWorktreeSpec): BranchWorktree { commitAndPush: (localConfig, message, files, options) => commitAndPushImpl(spec, localConfig, message, files, options), refresh: (localConfig, options) => refreshImpl(spec, localConfig, options), + checkOwner: (localConfig) => checkOwnerImpl(spec, localConfig), + isForeignByFiles: (localConfig) => isForeignByFilesImpl(spec, localConfig), }; } diff --git a/src/utils/learnings-publish.ts b/src/utils/learnings-publish.ts index 78152e522..25ac5d67d 100644 --- a/src/utils/learnings-publish.ts +++ b/src/utils/learnings-publish.ts @@ -11,10 +11,11 @@ import fs from 'node:fs'; import { ensureDir } from './fs.js'; import { learningsBranch } from './learnings-branch.js'; -import type { PublishResult } from './branch-worktree.js'; +import { CheckoutRefusedError, failureReason, type PublishResult } from './branch-worktree.js'; import { log } from './logger.js'; import { dropPendingLearning, + listPendingForInstall, listPendingLearnings, readPendingLearning, } from './pending-learnings.js'; @@ -31,6 +32,17 @@ export interface PublishQueueReport { * is still queued because publishing failed, not when the queue was empty. */ lastError?: string; + /** + * `lastError` is a checkout refusal: every pull meets it too, so the queue + * stays until the member does what the refusal says. + */ + refused?: true; + /** + * Why nothing was published at all: the install the command loaded is not + * this queue's any more (init switched its kind, or its config moved away), + * so the learnings stay where they are, for the install they belong to. + */ + installChanged?: string; } function commitMessageFor(username: string): string { @@ -47,7 +59,28 @@ export async function publishQueuedLearnings( username: string, options: { holdsSyncLock?: boolean } = {}, ): Promise { - const queued = await listPendingLearnings(localConfig); + const listing = await listPendingForInstall(localConfig); + switch (listing.status) { + case 'listed': + break; + case 'busy': + return { + published: [], + remaining: (await listPendingLearnings(localConfig)).length, + lastError: `another teamai command holds ${listing.lockPath}`, + }; + case 'changed': + return { + published: [], + remaining: 0, + installChanged: `this project's teamai install changed while this command ran (${listing.configPath} ${listing.cause})`, + }; + default: { + const unhandled: never = listing; + throw new Error(`Unhandled queue listing: ${JSON.stringify(unhandled)}`); + } + } + const queued = listing.queued; if (queued.length === 0) { return { published: [], remaining: 0 }; } @@ -78,7 +111,12 @@ export async function publishQueuedLearnings( // Never throw: a contribution is already safe in the queue, and publishing // it is never the reason a command fails. log.debug(`[learnings] publishing failed (non-blocking): ${(e as Error).message}`); - return { published: [], remaining: queued.length, lastError: (e as Error).message }; + return { + published: [], + remaining: queued.length, + lastError: failureReason(e), + refused: e instanceof CheckoutRefusedError || undefined, + }; } finally { if (syncLock) await releaseLock(syncLock); } @@ -168,6 +206,6 @@ async function publishToLearningsBranch( case 'busy': return { published: [], lastError: 'another teamai write is in progress' }; case 'failed': - return { published: [], lastError: result.reason }; + return { published: [], lastError: result.reason, refused: result.refused }; } } diff --git a/src/utils/learnings-roots.ts b/src/utils/learnings-roots.ts index f4587429d..3c7243981 100644 --- a/src/utils/learnings-roots.ts +++ b/src/utils/learnings-roots.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import { listFiles } from './fs.js'; import { learningsBranch } from './learnings-branch.js'; +import { ForeignCheckoutError } from './branch-worktree.js'; import { getDataHome, getKnowledgeDir, @@ -95,6 +96,23 @@ export function learningsRoots(localConfig: LocalConfig): LearningsRoots { return { write, read: dedupe(read) }; } +/** + * The read roots an index may be built from: all of them, except the write + * root when the learnings checkout there belongs to another repository (a + * git/self mode switch, #808). Everything else this project owns, the queue + * included, stays indexed. Probes the checkout, so only for index builds. + */ +export async function indexableLearningsRoots(localConfig: LocalConfig): Promise { + const roots = learningsRoots(localConfig); + try { + await learningsBranch.checkOwner(localConfig); + return roots.read; + } catch (e) { + if (!(e instanceof ForeignCheckoutError)) throw e; + return roots.read.filter((root) => root !== roots.write); + } +} + /** One learning file, and the root it actually lives in. */ export interface LearningFile { /** Path relative to its root, which is also the id votes are counted by. */ diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 38cc4a3ca..4660b869c 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -113,6 +113,10 @@ export function setSilent(s: boolean): void { silentMode = s; } +export function isSilent(): boolean { + return silentMode; +} + /** * Route non-error log output to stderr. Used by hook-dispatch commands to * keep stdout as a clean JSON channel for the AI tool. Returns the previous diff --git a/src/utils/partition.ts b/src/utils/partition.ts index 35601996f..2224f7833 100644 --- a/src/utils/partition.ts +++ b/src/utils/partition.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import YAML from 'yaml'; import { getUserHome } from './home.js'; import { readFileSafe, writeFileAtomic, expandHome } from './fs.js'; +import { managedMcpWorkspaceId } from '../types.js'; /** * Per-project data partition identity (issue #374 P1). @@ -331,3 +332,28 @@ export async function readAnchorFile(partitionDir: string): Promise/workspaces//` for every checkout that no longer + * exists: its search index, managed-MCP record and resource cache belong to a + * removed worktree (#808). `worktrees` are the repo's live checkouts, realpath'd + * as detection keys them (listWorktrees); an empty list proves nothing, so it + * removes nothing. Only directories named like a workspace id are touched. + * Returns the removed directories. + */ +export async function pruneWorkspaceDirs(dataHome: string, worktrees: readonly string[]): Promise { + if (worktrees.length === 0) return []; + const live = new Set(worktrees.map(managedMcpWorkspaceId)); + const workspaces = path.join(dataHome, 'workspaces'); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(workspaces, { withFileTypes: true }); + } catch { + return []; + } + const stale = entries + .filter((entry) => entry.isDirectory() && /^[0-9a-f]{12}$/.test(entry.name) && !live.has(entry.name)) + .map((entry) => path.join(workspaces, entry.name)); + await Promise.all(stale.map((dir) => fs.promises.rm(dir, { recursive: true, force: true }))); + return stale; +} diff --git a/src/utils/pending-learnings.ts b/src/utils/pending-learnings.ts index e4564e60c..0cdb53862 100644 --- a/src/utils/pending-learnings.ts +++ b/src/utils/pending-learnings.ts @@ -1,7 +1,13 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { ensureDir, listFilesRecursive } from './fs.js'; -import { isSelfMode, type LocalConfig } from '../types.js'; +import fse from 'fs-extra'; +import YAML from 'yaml'; +import { ensureDir, expandHome, listFilesRecursive, readFileSafe } from './fs.js'; +import { normalizeRepoUrlForCompare, redactGitCredentials, remotesMatch } from './git.js'; +import { log } from './logger.js'; +import { acquireLock, releaseLock } from '../update.js'; +import { getDataHome, getTeamaiHomeDir, isSelfMode, LocalConfigSchema, type LocalConfig } from '../types.js'; /** * Durable queue of learnings a member has written but that are not published @@ -11,17 +17,127 @@ import { isSelfMode, type LocalConfig } from '../types.js'; * It lives outside anything git rewrites: * - git: beside the clone, where pullRepo's diverged `reset --hard` on the * clone cannot reach it. - * - self: inside `.teamai/`, which is gitignored. The parent is the user's own - * product repo, where an untracked directory would show up in `git status` - * and be swept into a commit by `git add -A`. + * - self: in the data home, outside the user's own product repo, where an + * untracked directory would show up in `git status` and be swept into a + * commit by `git add -A`. Not in the checkout's `.teamai/`: every checkout + * shares the queue, and a removed worktree takes its ignored files with it + * (#808). */ export function pendingLearningsDir(localConfig: LocalConfig): string { if (isSelfMode(localConfig)) { - return path.join(localConfig.repo.localPath, 'pending-learnings'); + return path.join(getDataHome(localConfig), 'pending-learnings'); } return path.join(path.dirname(localConfig.repo.localPath), 'pending-learnings'); } +/** + * The lock that queue writes, the migration and init's install switch share for + * the queue kept in `home`, the directory holding `pending-learnings/` and the + * config that says whose queue it is. It lives under `~/.teamai/locks/`, not in + * `home`: the migration renames a checkout's `.teamai/` away, and a lock inside + * it would go with it, where a writer waiting on it would create the directory + * again. A home that is gone resolves without its real path; nothing holds that + * lock, and the writer then finds its config gone. + */ +export async function queueLockPath(home: string): Promise { + const real = await fs.promises.realpath(home).catch(() => path.resolve(home)); + const key = createHash('sha256').update(real).digest('hex').slice(0, 16); + return path.join(getTeamaiHomeDir(), 'locks', `queue-${key}.lock`); +} + +/** The directory `localConfig`'s queue is kept in, whose queue lock guards it. */ +export function queueHome(localConfig: LocalConfig): string { + return path.dirname(pendingLearningsDir(localConfig)); +} + +// Queue writes hold the lock for a file write; the migration holds it for a +// copy and a rename. Three seconds covers both without leaving a command stuck. +const QUEUE_LOCK_ATTEMPTS = 30; +const QUEUE_LOCK_RETRY_MS = 100; + +/** + * Take the queue lock of `home`, retrying while another command holds it. + * `acquired` is false when it still does after the wait; the caller must + * release `lockPath` otherwise. + */ +export async function acquireQueueLock(home: string): Promise<{ acquired: boolean; lockPath: string }> { + const lockPath = await queueLockPath(home); + for (let attempt = 1; ; attempt++) { + if (await acquireLock(lockPath)) return { acquired: true, lockPath }; + if (attempt === QUEUE_LOCK_ATTEMPTS) return { acquired: false, lockPath }; + await new Promise((resolve) => setTimeout(resolve, QUEUE_LOCK_RETRY_MS)); + } +} + +/** Run `fn` holding the queue lock of `home`, or report who holds it. */ +export async function withQueueLock( + home: string, + fn: () => Promise, +): Promise<{ status: 'done'; value: T } | { status: 'busy'; lockPath: string }> { + const { acquired, lockPath } = await acquireQueueLock(home); + if (!acquired) return { status: 'busy', lockPath }; + try { + return { status: 'done', value: await fn() }; + } finally { + await releaseLock(lockPath); + } +} + +/** + * Why the queue `localConfig` was loaded for is no longer its install's, or + * null when it still is. Asked under the queue lock, since a command loads its + * config long before it writes: the migration may have moved the config away + * (it retired the checkout's `.teamai/`, or relocated the file), or `init` + * pointed the project at another team repository, which would publish the + * queue. + */ +async function installChanged(localConfig: LocalConfig): Promise<{ configPath: string; cause: string } | null> { + const configPath = path.join(expandHome(getDataHome(localConfig)), 'config.yaml'); + const content = await readFileSafe(configPath); + if (content === null) return { configPath, cause: 'is gone' }; + let raw: unknown; + try { + raw = YAML.parse(content); + } catch { + return { configPath, cause: 'cannot be read' }; + } + const parsed = LocalConfigSchema.safeParse(raw); + if (!parsed.success) return { configPath, cause: 'cannot be read' }; + const now = queueOwner(parsed.data); + const loaded = queueOwner(localConfig); + if (now.kind !== loaded.kind) return { configPath, cause: `now names a ${now.kind} install, not ${loaded.kind}` }; + if (!sameQueueOwner(now, loaded)) { + return { configPath, cause: `now names ${redactGitCredentials(now.remote)}, not ${redactGitCredentials(loaded.remote)}` }; + } + return null; +} + +/** + * Where a queued learning went, or why it was not saved: another command held + * the queue lock for the whole wait, or the install this command loaded was + * migrated or switched to another team repository while it waited. + */ +export type QueueWrite = + | { status: 'saved'; path: string } + | { status: 'busy'; lockPath: string } + | { status: 'changed'; configPath: string; cause: string }; + +/** What a command that could not queue its learning tells the member. */ +export function queueWriteRefusal(result: Exclude): string { + switch (result.status) { + case 'busy': + return `Another teamai command is moving this project's queued learnings (${result.lockPath} is held). ` + + 'Nothing was saved. Run this again when it finishes.'; + case 'changed': + return `This project's teamai install changed while this command ran (${result.configPath} ${result.cause}). ` + + 'Nothing was saved. Run this again.'; + default: { + const unhandled: never = result; + throw new Error(`Unhandled queue write: ${JSON.stringify(unhandled)}`); + } + } +} + /** * Write a learning into the queue. * @@ -35,11 +151,41 @@ export async function savePendingLearning( localConfig: LocalConfig, relPath: string, content: string, -): Promise { - const dest = path.join(pendingLearningsDir(localConfig), relPath); - await ensureDir(path.dirname(dest)); - await fs.promises.writeFile(dest, content, 'utf-8'); - return dest; +): Promise { + // Only the write is locked: the publish that follows takes the sync and + // learnings locks, and no queue lock is held while waiting for another. + const locked = await withQueueLock(queueHome(localConfig), async (): Promise => { + const changed = await installChanged(localConfig); + if (changed) return { status: 'changed', ...changed }; + const dest = path.join(pendingLearningsDir(localConfig), relPath); + await ensureDir(path.dirname(dest)); + await fs.promises.writeFile(dest, content, 'utf-8'); + return { status: 'saved', path: dest }; + }); + return locked.status === 'done' ? locked.value : locked; +} + +/** + * The queue, as listPendingLearnings lists it, when it is still the queue of + * the install `localConfig` was loaded from. A publish runs long after the + * command loaded its config; had init switched the install meanwhile, the queue + * would hold what the new install queued, and this one would publish it to the + * previous repository. Listed under the queue lock, so nothing the switch + * leaves in the queue is on the list. + */ +export async function listPendingForInstall( + localConfig: LocalConfig, +): Promise< + | { status: 'listed'; queued: string[] } + | { status: 'busy'; lockPath: string } + | { status: 'changed'; configPath: string; cause: string } +> { + const locked = await withQueueLock(queueHome(localConfig), async () => { + const changed = await installChanged(localConfig); + if (changed) return { status: 'changed' as const, ...changed }; + return { status: 'listed' as const, queued: await listPendingLearnings(localConfig) }; + }); + return locked.status === 'done' ? locked.value : locked; } /** @@ -48,8 +194,12 @@ export async function savePendingLearning( * stray editor swap file never reaches the team repo. */ export async function listPendingLearnings(localConfig: LocalConfig): Promise { + return queuedLearningFiles(pendingLearningsDir(localConfig)); +} + +async function queuedLearningFiles(dir: string): Promise { try { - return (await listFilesRecursive(pendingLearningsDir(localConfig))) + return (await listFilesRecursive(dir)) .filter((relPath) => relPath.endsWith('.md')) .filter((relPath) => !relPath.split('/').some((segment) => segment.startsWith('.'))); } catch { @@ -79,3 +229,138 @@ export async function dropPendingLearning( ): Promise { await fs.promises.rm(path.join(pendingLearningsDir(localConfig), relPath), { force: true }); } + +/** The kind an install publishes to; a config written before `kind` existed is git. */ +export function installKind(localConfig: LocalConfig): string { + return localConfig.repo.kind ?? 'git'; +} + +/** + * Whose queue it is: the kind and the team repository the install publishes + * to (#823 item 13). Two installs of one team repository share it however its + * URL is written; any other install would publish it to the wrong repository. + */ +export type QueueOwner = { kind: string; remote: string }; + +export function queueOwner(localConfig: LocalConfig): QueueOwner { + return { kind: installKind(localConfig), remote: localConfig.repo.remote }; +} + +/** Same kind and team repository, ignoring credentials, protocol, `.git` and case (remotesMatch). */ +export function sameQueueOwner(a: QueueOwner, b: QueueOwner): boolean { + return a.kind === b.kind && remotesMatch(a.remote, b.remote); +} + +/** How many learnings `dir` queues: its `.md` files, none under a dot segment. */ +export async function countQueued(dir: string): Promise { + return (await queuedLearningFiles(dir)).length; +} + +/** + * Set a queue aside when a re-init switches the install to another kind (git + * and self mode share the partition's `pending-learnings/`, #808) or another + * team repository (#823 item 13): its learnings were written for the previous + * install's repository, and the new one would publish them to its own. They + * move to `pending-learnings.`, or `pending-learnings.-` for + * another repository of the same kind, beside it (a free name, nothing is + * overwritten or deleted) and the member is told how many and where. `save` + * writes the new config. Returns the directory the queue moved to, or null + * when nothing moved. + */ +export async function setAsideQueueOnModeSwitch( + previous: LocalConfig | null, + next: LocalConfig, + save: () => Promise, +): Promise<{ status: 'switched'; aside: string | null } | { status: 'busy'; lockPath: string }> { + // The loaders return null both for a fresh install and for a config that + // exists but cannot be read: that one names no owner, and the new install + // would publish its queue to its own repository. + const unreadable = path.join(queueHome(next), 'config.yaml'); + if (!previous && !fs.existsSync(unreadable)) { + await save(); + return { status: 'switched', aside: null }; + } + if (previous && sameQueueOwner(queueOwner(previous), queueOwner(next))) { + await save(); + return { status: 'switched', aside: null }; + } + // A command that loaded the previous config and queues meanwhile would put + // its learning into the queue the new install publishes: it waits, then + // finds the new install (savePendingLearning). + const dir = previous ? pendingLearningsDir(previous) : pendingLearningsDir(next); + const locked = await withQueueLock(queueHome(next), async () => { + const aside = path.resolve(dir) === path.resolve(pendingLearningsDir(next)) + ? await setAside(dir, next, previous ? queueOwner(previous) : { unreadable }) + : null; + await save(); + return aside; + }); + return locked.status === 'done' ? { status: 'switched', aside: locked.value } : locked; +} + +/** + * Set aside the queue an older `from` install kept in a checkout's `.teamai/` + * (#808), when the partition it would move into now serves `next`, another + * install: `init` switched the project from another checkout, and `next` would + * publish those learnings to its own repository. As a mode switch does. + */ +export async function setAsideCheckoutQueue(queue: string, next: LocalConfig, from: QueueOwner): Promise { + return setAside(queue, next, from); +} + +/** Whose queue is set aside: a known install, or one whose config at `unreadable` cannot be read. */ +type PreviousOwner = QueueOwner | { unreadable: string }; + +async function setAside(queue: string, next: LocalConfig, from: PreviousOwner): Promise { + const queued = await countQueued(queue); + if (queued === 0) return null; + const live = pendingLearningsDir(next); + // Another kind is named by its kind, as before; another repository of the + // same kind by that repository too, or the name would not tell them apart. + // A config nothing can read names no owner. + const repo = 'unreadable' in from ? '' : normalizeRepoUrlForCompare(from.remote).replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + const suffix = 'unreadable' in from ? 'unknown' + : from.kind === installKind(next) && repo ? `${from.kind}-${repo}` : from.kind; + let aside = `${live}.${suffix}`; + for (let n = 1; fs.existsSync(aside); n++) aside = `${live}.${suffix}.${n}`; + // A checkout's queue may sit on another filesystem than the data home. + await fse.move(queue, aside); + const settle = `Nothing was deleted. If they belong here, move the files into ${live}; ` + + 'otherwise contribute them again from an install of that repository.'; + log.warn('unreadable' in from + ? `Set aside ${queued} queued learning(s) in ${aside}: the previous install's config at ${from.unreadable} ` + + `could not be read, so nothing says which repository they were written for, and this ` + + `${installKind(next)} install would publish them to its own. ${settle}` + : `Set aside ${queued} queued learning(s) from the previous ${from.kind} install in ${aside}: ` + + `they were written for ${redactGitCredentials(from.remote) || 'that install\'s repository'}, and this ` + + `${installKind(next)} install would publish them to its own. ${settle}`); + return aside; +} + +/** + * Every queue in a data home with the learnings it holds: the live + * `pending-learnings/` and any set aside by a mode switch. For telling the + * member what deleting that data home would lose. The user one holds the + * project partitions (`projects//`), so their queues are listed too. + */ +export async function listQueuesIn(dataHome: string): Promise> { + let names: string[]; + try { + names = await fs.promises.readdir(dataHome); + } catch { + return []; + } + const queues = []; + for (const name of names.sort()) { + if (name !== 'pending-learnings' && !name.startsWith('pending-learnings.')) continue; + const dir = path.join(dataHome, name); + const count = await countQueued(dir); + if (count > 0) queues.push({ dir, count }); + } + if (names.includes('projects')) { + const projects = path.join(dataHome, 'projects'); + const slugs = await fs.promises.readdir(projects).catch(() => []); + for (const slug of slugs.sort()) queues.push(...await listQueuesIn(path.join(projects, slug))); + } + return queues; +} diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 262176f9e..3a66e8fe7 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -12,10 +12,12 @@ import fse from 'fs-extra'; import { createGit, getDefaultBranch, hasCommits } from './git.js'; import { pathExists } from './fs.js'; import { + ForeignCheckoutError, createBranchWorktree, isPublished, type BranchWrite, type EnsureWorktreeOptions, + type RefreshResult, } from './branch-worktree.js'; import { REPORTS_BRANCH, @@ -45,14 +47,59 @@ export function ensureReportsWorktree( return reportsBranch.ensure(localConfig, options); } -/** Best-effort refresh of the reports worktree from origin. Never throws. */ +/** + * The team's votes dir on the reports checkout, or undefined when the checkout + * there belongs to another repository (a git/self mode switch, #808): its votes + * are that team's, so nothing may rank or downvote from them. No checkout yet + * is fine (a caller finds no file). Probes the checkout, like + * indexableLearningsRoots, so only for index builds and explicit commands. + */ +export async function indexableVotesDir(localConfig: LocalConfig): Promise { + try { + await reportsBranch.checkOwner(localConfig); + } catch (e) { + if (e instanceof ForeignCheckoutError) return undefined; + throw e; + } + return path.join(reportsBranch.dir(localConfig), 'votes'); +} + +/** + * Best-effort refresh of the reports worktree from origin; busy when the lock + * cannot be taken, and then nothing is checked; failed when the checkout could + * not be set up. Throws only CheckoutRefusedError (#808). + */ export function refreshReportsWorktree( localConfig: LocalConfig, options: EnsureReportsWorktreeOptions = {}, -): Promise { +): Promise { return reportsBranch.refresh(localConfig, options); } +/** + * The reports checkout a reader lists from: refreshed and, if missing, + * created, without publishing a missing branch. While a write holds the lock, + * the local copy as it is: that write may be creating the checkout, so a + * reader must not add or remove it (#808). Throws ForeignCheckoutError when + * that copy is another repository's. + */ +export async function readableReportsWorktree(localConfig: LocalConfig): Promise { + const refreshed = await reportsBranch.refresh(localConfig, { pushIfCreated: false }); + switch (refreshed.status) { + case 'busy': + await reportsBranch.checkOwner(localConfig); + return reportsBranch.dir(localConfig); + // Failed: ensure tries once more, and throws its cause if it fails again. + case 'done': + case 'failed': + return reportsBranch.ensure(localConfig, { pushIfCreated: false }); + default: { + const unhandled: never = refreshed; + throw new Error(`Unhandled refresh result: ${JSON.stringify(unhandled)}`); + } + } +} + /** * Commit files already written into the reports worktree and push them. * diff --git a/src/utils/search-index.ts b/src/utils/search-index.ts index cfd484229..bf27e358d 100644 --- a/src/utils/search-index.ts +++ b/src/utils/search-index.ts @@ -1,10 +1,16 @@ import path from 'node:path'; +import { readdir, rm } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; import matter from 'gray-matter'; import { readFileSafe, readJson, writeJson, listFiles, listFilesRecursive, listDirs, pathExists } from './fs.js'; import { tokenize, wordSegments, MAX_TOKENIZE_CHARS } from './tokenizer.js'; import { log } from './logger.js'; import { SEARCH_INDEX_VERSION, + getDataHome, + getProjectSearchIndexPath, + isSelfMode, + type LocalConfig, type KnowledgeDomain, type LearningDocMeta, type SearchIndex, @@ -16,6 +22,51 @@ import { import { getUserHome } from './home.js'; import { isSafeNamespaceSegment } from '../manifest-schema.js'; +/** + * Self mode keeps one index per checkout, but every checkout shares the + * learnings checkout and the queue, so a rebuild in one leaves the others' + * indexes without what it just published or pulled (#808). Drop the other + * checkouts' indexes in this partition; recall rebuilds a missing one from + * that checkout's own roots. Only `search-index.json` files are touched. + */ +export async function dropOtherCheckoutIndexes(localConfig: LocalConfig): Promise { + if (!isSelfMode(localConfig)) return; + await dropCheckoutIndexes(getDataHome(localConfig), getProjectSearchIndexPath(localConfig)); +} + +/** + * Drop every per-checkout index under a self-mode data home, except `keep`. + * Also used when the migration moves queued learnings into the shared queue. + */ +export async function dropCheckoutIndexes(dataHome: string, keep?: string): Promise { + const workspaces = path.join(dataHome, 'workspaces'); + let entries: Dirent[]; + try { + entries = await readdir(workspaces, { withFileTypes: true }); + } catch (e) { + if (e instanceof Error && 'code' in e && e.code === 'ENOENT') return; + throw e; + } + await Promise.all( + entries + // One directory per checkout; a stray file (`.DS_Store`) has no index. + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(workspaces, entry.name, 'search-index.json')) + .filter((indexPath) => indexPath !== keep) + .map((indexPath) => rm(indexPath, { force: true })), + ); +} + +/** + * Drop every project search index in a data home: the shared one and each + * checkout's. For a re-init that changes the install's kind, which keeps the + * data home but not the repository its indexes were built from (#808). + */ +export async function dropAllSearchIndexes(dataHome: string): Promise { + await rm(path.join(dataHome, 'search-index.json'), { force: true }); + await dropCheckoutIndexes(dataHome); +} + /** Resolve search index path dynamically (respects HOME changes in tests). */ function getSearchIndexPath(): string { return path.join(getUserHome(), '.teamai', 'search-index.json'); diff --git a/src/viz.ts b/src/viz.ts index 9845601cc..430bfcffe 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -19,8 +19,7 @@ import { getUserLearningsDir, getKnowledgeDir, getReportsDir, - getDataHome, - getTeamaiHome, + getProjectSearchIndexPath, } from './types.js'; import type { SearchIndexEntry } from './types.js'; import { findPromotionCandidates, type PromotionCandidate } from './maintenance/promote.js'; @@ -136,7 +135,7 @@ interface VizPaths { * Precedence: explicit `--repo` flag → the cwd's scope (resolveConfigForDir: project, else user) * → ~/.teamai fallback. An unreadable project config throws rather than fall back. * `config.repo.kind` is 'git' | 'http' | 'self'. In self mode, votes/stats live in the reports - * worktree (ensureReportsWorktree), while learnings remain in the local ~/.teamai tree. + * worktree (readableReportsWorktree), while learnings remain in the local ~/.teamai tree. */ export async function resolveVizRoot(opts: VizOptions): Promise { // Explicit --repo: everything lives under the given repo; no shared index @@ -171,22 +170,27 @@ export async function resolveVizRoot(opts: VizOptions): Promise { if (config?.repo?.localPath) { const { usesBranchWorktree } = await import('./types.js'); if (usesBranchWorktree(config)) { - const { ensureReportsWorktree } = await import('./utils/reports-branch.js'); + const { readableReportsWorktree } = await import('./utils/reports-branch.js'); // Read-only: never publish a missing reports branch. - await ensureReportsWorktree(config, { pushIfCreated: false }); + await readableReportsWorktree(config); } const knowledgeRoot = getKnowledgeDir(config); const reportsRoot = getReportsDir(config); const useProjectScope = config.scope === 'project' && Boolean(config.projectRoot); - // Project branch routes through getDataHome (P1-2 partition-aware); the - // else branch preserves the original fallback to ~/.teamai for historical - // configs that are project-scoped but lack projectRoot (getDataHome would - // otherwise throw via getTeamaiHome and fail the dashboard report). - const teamaiHome = useProjectScope ? getDataHome(config) : getTeamaiHome('user'); - const { learningsRoots } = await import('./utils/learnings-roots.js'); + // Project branch routes through getProjectSearchIndexPath (partition-aware, + // per checkout in self mode); the else branch preserves the original + // fallback to ~/.teamai for historical configs that are project-scoped but + // lack projectRoot (getDataHome would otherwise throw via getTeamaiHome and + // fail the dashboard report). + const indexPath = useProjectScope ? getProjectSearchIndexPath(config) : getUserSearchIndexPath(); + const { learningsRoots, indexableLearningsRoots } = await import('./utils/learnings-roots.js'); const roots = learningsRoots(config); const learningsDir = useProjectScope ? roots.write : getUserLearningsDir(); - const learningsDirs = useProjectScope ? roots.read : [getUserLearningsDir(), ...roots.read]; + // Not another repository's learnings checkout (#808): the temporary index + // and the promotion/prune candidates are built from these. + const learningsDirs = useProjectScope + ? await indexableLearningsRoots(config) + : [getUserLearningsDir(), ...roots.read]; const source: VizSource = config.repo.kind === 'self' ? { scope: 'local', label: 'Personal repo · your recalls only' } : { scope: 'team', label: 'Team repo · aggregated across the team' }; @@ -197,7 +201,7 @@ export async function resolveVizRoot(opts: VizOptions): Promise { learningsDir, learningsDirs, statsDir: path.join(reportsRoot, 'stats'), - indexPath: path.join(teamaiHome, 'search-index.json'), + indexPath, source, }; } diff --git a/src/votes.ts b/src/votes.ts index e866105e1..22c8dbd6b 100644 --- a/src/votes.ts +++ b/src/votes.ts @@ -453,7 +453,7 @@ export async function recallFeedback(opts: { positive?: string; negative?: strin process.exitCode = 1; return; } - const { getVotesDir, getReportsDir } = await import('./types.js'); + const { getVotesDir } = await import('./types.js'); const votePath = path.join(getVotesDir(localConfig), `${localConfig.username}.yaml`); if (opts.positive) { @@ -469,13 +469,18 @@ export async function recallFeedback(opts: { positive?: string; negative?: strin } if (opts.negative) { + // Not another repository's reports checkout (#808): those votes are that team's. + const { indexableVotesDir } = await import('./utils/reports-branch.js'); + const teamVotesDir = await indexableVotesDir(localConfig); // Serialize under the same per-file lock as upvote/sync so a manual // downvote cannot race the detached judge or a sync clearing deltas. const { acquired, value } = await withVotesLock(votePath, async () => { const data = await loadUserVotes(votePath); // The scope's own file starts empty on upgrade (#787), so the upvotes its // team already holds count too: that file plus the deltas not yet pushed. - const team = await loadUserVotes(path.join(getReportsDir(localConfig), 'votes', `${localConfig.username}.yaml`)); + const team: UserVotesV2 = teamVotesDir === undefined + ? { version: 2, votes: {}, deltas: {} } + : await loadUserVotes(path.join(teamVotesDir, `${localConfig.username}.yaml`)); const teamEntry = team.votes[opts.negative!]; const known = data.votes[opts.negative!] ?? teamEntry; if (!known) return 'missing' as const;