diff --git a/CLAUDE.md b/CLAUDE.md index 69d15d9e..2cbbbc7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,6 +251,8 @@ codeoid routine add --prompt … [--cron|--every|--at] [--check ] # codeoid routine add --loop --prompt … --verify "id: cmd" [--test] [--attempts N] [--attempt-minutes N] [--run-minutes N] [--budget N] # start a loop (--check = gate, --verify = its acceptance checks) codeoid routine ls | show | runs | run [--wait] | edit | pause | resume | rm | cancel | token | preview codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a repo's .codeoid/setup.yaml, pinned to its blob (docs/CONFIGURATION.md) +codeoid workstream ls | show | match [dir] [--workstream ] # named lines of work and what a folder resolves to (docs/workstreams-design.md) +codeoid new --workstream # a session's home workstream; default: the folder's, else workstreams.default ``` `/mcp/control` (off by default, `mcpControl.*`) lets external agents drive @@ -516,6 +518,25 @@ On Claude the PreToolUse hook routes high and not-understood ones to the gate pa Autonomous mode never waits on a person: the OS sandbox (#448) is its boundary (it doesn't start without one that runs, or the owner's own off). So in autonomous mode a shell command of any level runs (`#riskAsks` is false there; the level goes to the audit, `session.auto_approve … risk=high`), and a write runs where the sandbox lets it land (a host-side file tool beyond its spec is refused there, `#outsideSandbox`). What the sandbox can't be left to hold is refused at once, never asked (`Session#autonomousRefusal`, shared by the gate and the tool_start card; `#refuseAutonomous`: the reason to the agent and on the card, an info line `autonomous.refused`, audit `session.autonomous_refuse kind=…`, no budget spent): `unsandboxed` (`#unsandboxedRequest`), `held_write` (`#heldWrite`), `claude_settings` (`#writesClaudeSettingsOnNamedAccount`), `credential_dir` (a secret-path `ask`, or a shell risk carrying one on a session that isn't sandboxed: `ls ~` runs inside one), `unresolved_path` (`isUnresolvedPathTool`). Credential reads stay refused in every mode. Elicitation tools and fleet send-class dispatch still ask (so they are all an unattended run parks on). When the budget runs out (park) the session reverts to guarded and says so (`#noteAutonomousBudgetSpent`: info line `budget.spent`, `SessionInfo.autonomousBudgetSpent` until a mode is set, `modeSummary` in `codeoid ls`). Guarded and interactive are unchanged. `approvals.askOnUnknown` is inert. Relative paths are judged from the cwd the backend reports (`ToolApprovalFn`'s `shellCwd`: Claude's PreToolUse hook `cwd`, kept for every tool, since the CLI resolves Bash's targets and a Grep/Glob relative `path` from its persisted cwd; null = unknown, asks). Claude's `Monitor` command is a shell call, `PowerShell` never understood. Claude's CLI gets `CDPATH=` and opencode's shells have none, so a bare `cd` goes where the shell reader places it. Codex autonomous asks nothing (#442). A command that could read another process's environment (a `/proc//environ` however written, what a search of /proc hands on, `ps` unless its options and the line's variables prove it shows none) is refused as a credential read on every backend, failing closed (`refuseEnvironRead`, shell-risk.ts); a word's possible values are its `shape` (parse.ts). +Workstreams (`src/daemon/workstreams/`, docs/workstreams-design.md): a session's +home workstream is decided once by `WorkstreamService` (`forCreate`: explicit > +folder match > `workstreams.default`; `forResume`: a stored binding sticks, a +pre-workstreams session is homed once by its folder, `source: "migrated"`) and +kept by the Session in `TranscriptMeta.workstream`, written in BOTH meta saves +and validated when read (`readWorkstreamBinding`). Matching is pure +(`match.ts`: an exclusive claim outranks a suggestion, then the most specific +RULE wins, never the match location; a path rule beats a remote rule; +`upstream`-only matches only suggest); `folder.ts` reads a repository's remotes +from its git config without running git (non-blocking opens: a planted FIFO +must not hang the daemon) and gives a folder's two spellings only — as given +and real — never links found by scanning; spellings that disagree leave no +home. A stored binding that is malformed fails closed (`source: "none"`), never +re-matched; the resume pre-pass (`forResume`) lets forks and children inherit, +and the decided home is written with `TranscriptStore.patchMeta` after resume +succeeded, touching no other field. Every `new Session` site in SessionManager passes +`#workstreamOpts(...)`; children and forks inherit, the conductor's is reserved. +With no workstreams configured no binding exists and nothing changes. + Routines (`src/daemon/routines/`, docs/routines.md) run sessions on a schedule or an API call. `RoutineService` owns the scheduler and runs; it reaches sessions only through the `RoutineHost` seam that `SessionManager` implements, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fefbc5f4..b080e6c6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -363,6 +363,52 @@ Prefer a command that runs no repository scripts, such as `bun install --ignore- A setup failure is reported as an infrastructure problem, not the agent's; a routine run fails before its agent starts. Recipes aren't supported on Windows. +## Workstreams + +A workstream is a named line of work: a company, a client, a project, a team, a personal setup. +Each session belongs to exactly one, its home, and the home decides what the session is given. +The design is [workstreams-design.md](workstreams-design.md). + +```jsonc +"workstreams": { + "default": "personal", // home for a folder nothing matches; leave out to have none + "isolateGlobalServers": false, // on: a session gets only its workstream's servers (W1) + "items": [ + { "id": "acme", "name": "Acme", "claim": "exclusive", + "match": { "paths": ["~/Workspace/acme-*"], "remotes": ["github.com/acme/*"] } }, + { "id": "research", "match": { "paths": ["~/Workspace/research/**"] } }, + { "id": "personal" } + ] +} +``` + +A session's home is decided at create: `--workstream ` (or the web picker), else the folder's, else `default`. +`codeoid workstream match ` says what a folder resolves to and which rule decided it; `codeoid workstream ls` and `show ` list the configuration. + +Matching: + +- A path rule matches the folder it names and everything below it; `~` is your home; `x`, `x/` and `x/**` are the same rule. + A folder matches as you gave it and as its real path, and a rule whose fixed part (`~/Workspace` in `~/Workspace/acme-*`) is a symlink matches through it too. + Nothing else: links are never searched for. If the path you gave and the real path match different workstreams, the folder gets no home until you choose one. +- A remote rule matches the repository's `origin` (or `upstream`) as `host/owner/repo`, read from the repository's git config without running git. + An ssh `Host` alias in `~/.ssh/config` reads as its `HostName`. + A match through `upstream` alone only suggests the workstream. +- An exclusive claim beats any suggestion. Then the most specific rule wins: any path rule beats any remote rule, then the rule with more literal folders, then more literal characters. + Two workstreams tied on a folder give it no home until you choose one. Remote rules are case-insensitive. + +`claim: "exclusive"` means the folder runs only with its own workstream: choosing another one for a session there, or forking a session of another workstream into it, is refused. +A delegated token can confirm a folder's own workstream but not choose another; every choice is in the audit log (`session.workstream`). +`claim: "suggest"` (the default) only pre-selects it. + +A session keeps its home. +A restart never matches the folder again, so changing the rules affects new sessions only. +A session from before workstreams were configured is given its folder's home once, the first time it resumes (`source: migrated`). +A home deleted from the config leaves its sessions marked orphaned; removing the whole `workstreams` block turns workstreams off instead. +A session whose stored home is unreadable or invalid gets no home (it is never re-matched to its folder). + +This is phase W0: a session's workstream is decided, kept and shown, and nothing it is given changes yet. +Servers, credentials, git identity and memory follow in later phases (the design's §13). + ## Signing a backend in, instead of pasting a key Settings → **Claude** → **Sign in with Claude** runs the backend's own login diff --git a/docs/workstreams-design.md b/docs/workstreams-design.md new file mode 100644 index 00000000..4e5b35f7 --- /dev/null +++ b/docs/workstreams-design.md @@ -0,0 +1,534 @@ +# Codeoid Workstreams — Design Proposal + +> Status: **DRAFT for grilling** · Author: design session 2026-11-10 +> Companion to [conductor-design.md](./conductor-design.md) and [federation-design.md](./federation-design.md). +> Goal: let one owner run several separate lines of work on one daemon. A line of work can be a company, a client, a project, a team, research, or just a different personal setup. +> Each line of work gets its own MCP servers, credentials, models and memory. +> The conductor still sees and coordinates all of them, and nothing leaks from one to another by default. + +--- + +## Decisions locked (grill 2026-11-11) + +- **G1. Daemon-held credentials, always proxied.** Every credentialed server a workstream selects is reached through the daemon's hub and proxy (`mcp/hub.ts`, `mcp/proxy.ts`), the path OAuth servers already use. + A backend process (argv, env or `--mcp-config`) never holds a workstream secret. It gets a local proxy URL and a per-session proxy token. + The hub's client cache is keyed by `(server, credential slot)`. + Stdio servers are the one exception: the hub spawns them, so their secret goes into the env of a subprocess the hub owns, never a backend's. +- **G2. Credentials are named, first-class objects behind a pluggable store.** + The declaration (`config.json` → `credentials[]`: `id`, `kind` = `bearer | header | env | oauth`, plus kind fields) is metadata only. + Values sit behind a `CredentialStore` interface (`get / put / delete / status / writable`). The default implementation keeps static values in `.env` as `CODEOID_CRED_`, which `DENY_PREFIXES` keeps out of every agent env. OAuth tokens go in `mcp_oauth_credentials`, which gains a `credential` column in its primary key (`''` = default sign-in, so existing rows are unchanged). The PK change is a table rebuild in one transaction. + Other implementations (file vault, Kubernetes Secret, …) plug in by config. A read-only store (`writable: false`) shows "managed externally" in the UI, and OAuth tokens can use a different, writable store. +- **G3. Override = a two-step create with a bound token.** A conflicting `session.create` returns `workstream_conflict` with the diff and an `overrideToken`. + The token is held in daemon memory, is single-use, expires after 5 minutes, and is bound to (caller subject, workdir, chosen workstream, config revision). + The retry carries the token, and the caller must hold `session:approve`. + For the conductor, the override warning is placed on `fleet_spawn`'s existing approval card, and that approval is the confirmation. The conductor never receives a token. + Rejected: a pending session with a synthetic approval. Approvals are `canUseTool` gates inside a live turn (`session.ts:1318`) and aren't persisted across restarts. +- **G4. Revocation applies on every call. The tool list catches up at the turn boundary.** + The proxy checks the session's *current* binding on every credentialed call, so a removed server or credential fails at once, mid-turn, with an error naming the workstream. + The tool list the model sees is rebuilt at the next turn boundary through the existing query-loop rebuild. + Widening waits for refresh or a new session. +- **G5. The binding is stored in `TranscriptMeta.workstream` and sticks.** Resume re-checks the stored home against the current config and never re-matches folders. + A deleted workstream leaves its sessions `orphaned`, which refuse turns until moved. + A session from before workstreams is resolved once by folder rules on its first resume with workstreams configured, and saved with `source: "migrated"`. + If nothing matches and there's no default, it **fails closed**: built-ins only, with a banner asking you to pick. + Forks and collaboration children inherit the parent's binding. +- **G6. Delivery.** Branch `feat/workstreams`, one PR per phase into `main`. Each PR gets two deep audit rounds, with fixes folded in. After merge, an end-to-end test of the phase, with follow-up fix PRs until it works. + W4 builds the fleet-tool scoping now, and leaves workstream-homed egress as a tested stub until P6's Slack and email egress exists. + Mobile changes wait for the app revival. Federation (W5) is out of this round. + +--- + +## 1. The gap + +Today every session on a daemon gets the same tool surface. + +- `loadUserMcpServers` (`providers/claude/index.ts`) merges the user scope of `~/.claude.json` into every Claude session. + The session view (`mcp/session-view.ts`) hands the same global servers to every other backend. +- So a session working on one project is handed a server that can query another project's production database. + A session in one team's repo is handed another team's GitHub token. + Nobody chose this. It is simply what "user scope" means. +- Per-folder servers already exist (`mcp/workdir-servers.ts`, #395), but they are keyed by **one exact path**. + An owner with forty worktrees of one project's repos would need forty entries, kept in sync by hand. +- Memory is scoped by tenant `(account, project)` and by **workspace** (`workspaceIdFromPath`, `memory/store.ts:107`), which is a single folder. + Nothing groups folders into "all my Sentisum work", so recall cannot be asked for it and cannot be fenced to it. +- The conductor (conductor-design §5–§8) is about to draft Slack replies and chase reviews on the owner's behalf. + Without a boundary it can compose a reply in one Slack workspace using recall from another workstream's sessions. + That is the failure the owner cares about most, and nothing in the current model can express it. + +## 2. Goal and non-goals + +**Goal.** A **workstream** is a named line of work. +It decides, for every session that belongs to it: which MCP servers and credentials the session gets, which accounts and models it uses by default, and which memory it writes to and may read. +Sessions join a workstream automatically by folder, or by explicit choice. +They may read another workstream's code only when that workstream allows it. + +**A workstream is whatever boundary the owner wants.** It is not tied to an employer. +Examples: a company, a client engagement, one product inside a company, a team, a research effort, or a personal setup such as "local models only, no cloud servers". +Workstreams don't have to be strict silos either. They can share servers, accounts and skills on purpose (§6.5). +The policies below (`claim`, `share`, `allowOverride`) let each workstream be as open or as closed as its contents need. + +**Non-goals.** +- **Not a new tenant.** Tenancy `(account, project)` is the authorization boundary *between principals*. + Workstreams partition *one owner's* work inside a tenant (see §11, rejected alternatives). +- **Not a sandbox.** Credential isolation is enforced (§6). File-read isolation is a gate on file tools plus a prompt on `Bash`, **not** an OS fence (§7.3). + The real fence is the same open sandbox question as conductor-design open question #10. +- **Not mandatory.** A daemon with no workstreams configured behaves exactly as it does today. + +## 3. Vocabulary + +| Term | Meaning | Exists today | +| --- | --- | --- | +| **tenant** | `(account_id, project_id)` from the caller's JWT. The auth boundary. | yes | +| **workspace** | One folder (git root or path). The memory key, `workspaceIdFromPath`. | yes | +| **workstream** | A named set of workspaces plus policy: servers, credentials, accounts, models, memory, sharing. | **new** | +| **home workstream** | The one workstream a session belongs to. Decides everything the session is *given*. | **new** | +| **reference** | Read-only access a session holds to another workstream's folders. | **new** | + +A session has **exactly one** home workstream, and zero or more references. +That single rule answers all three of the owner's questions (§5). + +## 4. The workstream object + +```jsonc +// ~/.codeoid/config.json → "workstreams" +{ + "workstreams": { + "isolateGlobalServers": true, // §6.1 — off = today's behaviour + "default": "personal", // home for sessions nothing matches + "items": [ + { + "id": "highflame", + "name": "Highflame", + "color": "orange", + "match": { + "paths": ["~/Workspace/highflame-*", "~/Workspace/highflame-*-worktrees/**", "~/Workspace/zeroid-main"], + "remotes": ["github.com/highflame-ai/*"] + }, + "claim": "exclusive", // §5.2 + "mcpServers": ["github-hf", "highflame-platform", "highflame-platform-prod", "slack-hf"], + "accounts": { "claude": "default" }, + "defaults": { "provider": "claude", "model": "claude-opus-5-5", "effort": "xhigh" }, + "env": { "GH_TOKEN": { "secret": "gh-rdkrds" } }, + "skillDirs": ["~/Workspace/highflame-architecture/claude/skills"], + "share": "ask" // §7 — may other workstreams reference this one? + }, + { + "id": "sentisum", + "match": { "paths": ["~/Workspace/sentisum/**"], "remotes": ["github.com/sentisum/*"] }, + "claim": "exclusive", + "mcpServers": ["github-sentisum"], + "share": "closed" + }, + { + "id": "dhishvari", + "match": { "paths": ["~/dhishvari/**"], "remotes": ["github.com/Dhishvari/*"] }, + "claim": "exclusive", + "env": { "GH_TOKEN": { "secret": "gh-saucam" } }, + "share": "open" + }, + { + "id": "research", + "match": { "paths": ["~/Workspace/gemma-4-comp/**", "~/Workspace/training/**"] }, + "claim": "suggest", + "defaults": { "provider": "claude", "model": "claude-opus-5-5" }, + "share": "open" + }, + { "id": "personal", "claim": "suggest", "share": "open" } + ] + } +} +``` + +Field notes: + +- **`match.paths`** are globs, with `~` expanded. Globs, not regex: they read the way people write paths, and a regex is one bad `.*` away from matching `$HOME`. + A `regex:` prefix can be added later if a real case needs it. +- **`match.remotes`** match the git `origin` URL, normalised to `host/owner/repo`. + This is the rule that **survives federation**: a repo lives at `/home/deploy/Workspace/...` on one machine and `/home/ydatta/Workspace/...` on another, but its remote is the same (§9). +- **`mcpServers`** name entries in the existing registry (`mcp/registry.ts`). A workstream doesn't define servers. It *selects* them. + Servers stay defined once, in the MCP Servers panel, and keep their OAuth state there. +- **`env`** values are **secret references**, never inline secrets, so the config file is safe to show, diff and send to a satellite. +- **`accounts`** reuse the per-backend accounts from [backend-accounts-design.md](./backend-accounts-design.md). A workstream only names which account is the default. +- **`skillDirs`** are added to the skill directories (the `--add-dir` list) for the workstream's sessions only. + Today every session gets `highflame-architecture/claude/skills`, which is the same leak in a smaller form. + +## 5. How a session gets its home workstream + +### 5.1 Resolution order + +At `session.create`: + +1. **Explicit**: `SessionCreateMsg.workstream` was set (new picker, `codeoid new --workstream`, `fleet_spawn(workstream=…)`). +2. **Matched**: the workdir matches a workstream's rules. + The most specific **rule** wins: a path glob beats a remote rule, then the rule with more literal folders, then more literal characters, and `origin` beats `upstream`. + Where a rule matched never counts: `~/w/**` matching the folder itself must not beat `~/w/acme` matching its parent (found while building W0). + The same rule written in two workstreams is a **config error** when the config loads. + Two *different* rules that tie on a real folder can only be found against that folder, so they are reported at resolution: the folder gets no home until a workstream is chosen, and the picker shows the tie. + A folder matches as given and as its real path, and a rule with a symlinked fixed part (`~` itself, or `~/Workspace`) through its real path too. + Links are never searched for: the W0 audit showed a link anyone plants under a rule's folder becomes a spelling an attacker chose. When the given and real spellings match different workstreams, the folder gets no home until one is chosen. + **An exclusive claim outranks any suggestion**, whatever the specificity (W0 audit): a more specific `suggest` rule must not take a folder from the workstream holding it exclusively. Carving one out takes a more specific exclusive rule. + + **A remote is text an agent can edit.** `.git/config` is in the repository, so an agent that can write it could make the folder's *next* session resolve to a more privileged workstream. + Three things bound this: a path rule outranks any remote rule, a live session's home never changes, and (W1) a remote-derived match that differs from how the folder resolved before is treated as a conflict, not a silent re-homing. + Under the OS sandbox (#448) the repository's `config` is read-only to the agent anyway. +3. **Default**: `workstreams.default`, if one is set. + The default is optional (decided 2026-11-10). + With none set, a session in an unmatched folder has no home until you pick one: the dialog asks, and `codeoid new` without `--workstream` fails with the list. + A session with no workstream is never silently given the global servers. + +The result is stored in the session meta as a **binding**: + +```ts +interface WorkstreamBinding { + id: string; // home workstream + source: "explicit" | "matched" | "default" | "override"; // override = confirmed past an exclusive claim (§5.2) + matchedBy?: string; // the rule that matched, for display + revision: string; // config revision applied (§8): a hash of the workstreams block + references: Reference[]; // §7 +} +``` + +### 5.2 When the explicit choice and the folder disagree + +This is the owner's second question. The answer depends on the folder's **claim**: + +| The folder is… | You pick a different workstream | Result | +| --- | --- | --- | +| unmatched (e.g. `/tmp/scratch`) | any | **allowed**. The explicit pick is the home. | +| matched by a `suggest` workstream (`research`, `personal`) | any | **allowed**. The picker shows "folder suggests *research*", and your choice wins. | +| matched by an `exclusive` workstream (`highflame`, `sentisum`) | another one | **you are warned and asked to confirm** (decided 2026-11-10). The create returns `workstream_conflict`, and the dialog shows three choices. | + +The three choices on a conflict: + +- **(a) Use the folder's workstream.** You are working in that repo, so you are doing that workstream's work. This is the right answer almost every time, because the mismatch is usually a slip of the picker. It is the default button. +- **(b) Start in your chosen workstream with that folder as a read-only reference.** The session's workdir becomes a scratch folder or a worktree in the chosen workstream's area. The conflicting folder is attached as a reference (§7), if its `share` policy allows that. +- **(c) Override: start in the folder, homed in your chosen workstream.** The warning names what will happen: "This folder belongs to *Sentisum*. The session gets *Dhishvari*'s servers and `GH_TOKEN` and writes into Sentisum's code." + Confirming sets the binding's `source: "override"`, records an audit row naming both workstreams, and puts a warning badge on the session chip for its lifetime. + The folder's own workstream's servers and env are still withheld, because an override changes the home and does not merge the two. + +The override confirmation is enforced by the daemon, not a client-side checkbox (G3). The conflict reply carries a single-use `overrideToken` bound to the request, and the confirming retry must present it and hold `session:approve`. +The conductor's `fleet_spawn` cannot confirm its own override: the warning goes on the spawn's approval card, and only your approval confirms it. +A workstream can set `allowOverride: false` to make (c) unavailable for its folders. That is the hard refusal, for code that must never run under another identity, such as a client's repo under a contract. + +Why the default answer is (a) and (c) takes a confirmation, instead of letting the explicit choice silently win: + +- **The home decides credentials.** If you could home a session in `dhishvari` while its workdir is a Sentisum checkout, the agent could read and write Sentisum code freely. + It could also push with the Dhishvari GitHub token or commit under the wrong identity. The folder's claim exists to stop exactly that. +- **The opposite direction is worse.** If you could home a session in `sentisum` while sitting in a Dhishvari repo, Sentisum's servers could be pointed at your own company's code. An `exclusive` claim protects both sides. + +`claim: exclusive` suits workstreams whose folders should only ever run with their own credentials and identity. That is usually because the code or the credentials belong to someone else (a client, an organisation) or because a wrong-identity commit or push would be costly. +`claim: suggest` suits everything else: research, scratch work, personal setups. The folder hints at the workstream and never gets in the way. + +### 5.3 Changing workstream after creation + +`session.set_workstream` re-homes a live session. It is treated as a **share of the session's whole transcript** from the old workstream to the new one, because that is what it is: the conversation carries everything the session has read. + +- It is allowed only if the old workstream's `share` policy allows the new one (§7.1): `closed` refuses, `ask` needs approval, `open` is allowed. +- Moving from the `default` workstream is always allowed. This covers "I started a scratch session and now it's real work". +- The backend restarts at the next turn boundary with the new tool surface, the same cost model as an MCP refresh. + The move is audited as one event. +- A **fork** into another workstream follows the same rule, because a fork also carries the transcript. + +## 6. What the home workstream controls + +### 6.1 MCP servers — enforced + +For a session homed in workstream *W*, the session's MCP view is: + +``` +built-ins (codeoid_memory, codeoid_fleet on the conductor, blackboard, …) + ∪ servers listed in W.mcpServers + ∪ the workdir's own per-folder servers (#395), but only if the folder is in W +``` + +The user's global Claude servers (`~/.claude.json` user scope): + +- **`isolateGlobalServers: false`** (default, back-compatible): they reach every session, as today. +- **`isolateGlobalServers: true`**: a global server reaches only the workstreams that list it by name. + An unlisted global server reaches nobody, and the settings panel shows it as "not in any workstream". + +**Enforcement on Claude.** The Claude CLI loads `~/.claude.json` itself, so it isn't enough for codeoid to leave a server out of what it passes. +Withheld servers go into `settings.deniedMcpServers` by URL. The provider already does exactly this for OAuth servers (`providers/claude/index.ts:1175`), so this widens an existing mechanism instead of adding one. +On every other backend, codeoid builds the server list itself, so leaving a server out is enough. +Qwen reads a project's `.mcp.json` itself (session-view.ts header). That only matters for a folder inside *W*, so it stays inside the boundary. + +This is the one control that makes the owner's split real: **a session can't call a tool it was never given.** It holds no matter what the model is told. + +### 6.2 Credentials and environment — enforced + +`W.env` is merged into the backend's environment after codeoid's own scrubbing (`buildQwenEnv` and the shared prefix-strip). +A `GH_TOKEN` for one workstream is simply absent from another's processes. +The daemon resolves secrets when it spawns the backend and never writes them to session meta or the transcript. + +**Git identity** comes from the same mechanism. A workstream can set `git: { name, email }`, which becomes `GIT_AUTHOR_*` and `GIT_COMMITTER_*` in the backend's env. +Commits in a workstream's sessions then carry its identity no matter what the repo's local git config says. +It is cheap, and it prevents a whole class of wrong-identity commits. + +### 6.3 Accounts, models, effort, skills — defaults + +These fill fields the create message left empty: `providerId`, `account`, `model`, `effort`. +An explicit value on the create message wins, so these are defaults and not a fence. +The exception is an account belonging to another workstream. Picking it would hand the session that workstream's subscription or API key, so it is refused just like a server is withheld. + +### 6.4 Memory — scoped + +- Every episode and session card records the session's home workstream (a `workstream_id` column, written at capture). + Workspace ids don't change, and no re-keying is needed. +- `recall` from a session searches **its home workstream plus its references** by default. "All" is available only to the conductor (§10). +- A `closed` workstream's episodes never show up in another workstream's recall, including through a reference grant made before the policy changed (§8). + +### 6.5 Sharing resources between workstreams + +Workstreams **select** resources. They don't own them. +An MCP server, an account, a skills folder or a pack is defined once, in its own panel, and any number of workstreams can list it. +Sharing is therefore the normal case, not a special feature. There are three kinds: + +**1. Shared server and shared credential.** Two workstreams list the same server, and both use the same sign-in or token. +Typical examples: a web-search server, a local model server, your own notes or calendar, a design tool. + +```jsonc +{ "id": "dhishvari", "mcpServers": ["web-search", "notes", "github"] } +{ "id": "vibzart", "mcpServers": ["web-search", "notes", "github"] } +``` + +**2. Shared server, separate credential per workstream.** The server definition is the same, but each workstream signs in as itself. +The common case is one GitHub MCP server used with a different account or token per workstream. + +```jsonc +{ "id": "dhishvari", "mcpServers": [{ "server": "github", "credential": "gh-saucam" }] } +{ "id": "research", "mcpServers": [{ "server": "github", "credential": "gh-research-bot" }] } +``` + +- For a header-authenticated server, `credential` names a secret, and the daemon sets the header from it when it builds the session's view. +- For an OAuth server, the OAuth token store (`mcp/oauth.ts`, already keyed by `McpTenant`) gains a credential slot. Each slot has its own sign-in, started from the Workstreams panel. +- Leaving `credential` out means the server's default sign-in, which is case 1. + +**3. Shared base.** A workstream can `extends` one or more **base** workstreams. +A base has no `match` rules and no sessions of its own. It holds the common set, and each workstream adds to it or takes away from it: + +```jsonc +{ "id": "base-personal", "abstract": true, + "mcpServers": ["web-search", "notes"], "skillDirs": ["~/.claude/skills"], + "accounts": { "claude": "max-personal" } } +{ "id": "dhishvari", "extends": ["base-personal"], "mcpServers": ["+github"], "exclude": ["notes"] } +``` + +Merge rules: lists are unioned in `extends` order, the workstream's own entries come last, and `exclude` is applied after the merge. Scalars are overridden by the most specific setting. +A cycle in `extends` is a config error, reported when you save. +`claim`, `share` and `match` are **never inherited**, because they describe *this* workstream's folders, not a common setup. + +**Shared beyond servers:** + +| Resource | Shareable | Notes | +| --- | --- | --- | +| MCP servers | yes | as above | +| Accounts (subscriptions, API keys) | yes | One Claude subscription across workstreams is normal. Its quota is shared too, which is what the deferred spend cap (§15) would split. | +| Skills folders, packs | yes | via `skillDirs` and `packs`, or through a base | +| Routines | no | A routine runs in one workstream, its home. A routine that should exist in several workstreams is defined once per workstream. | +| Memory | only through references | Reading another workstream's memory is a reference (§7), governed by the `share` policy of the workstream being read. A base workstream holds no memory. | + +**The one warning: a shared credential is a bridge.** +If two workstreams use the same Slack sign-in, a session in either can read the channels of both, whatever `share` says. +A server is just a tool, and the tool sees what its credential sees. +So the panel shows "shared by *N* workstreams" on each server and credential. +It **warns, without blocking**, when a credential is shared with a workstream whose `share` is `closed`, since that sharing contradicts the policy. +The fix is case 2: the same server with a separate credential per workstream. + +### 6.6 Channels (for the conductor's inbox) + +A workstream can also name its **channels**: the places messages come from and replies go to. +Examples: a Slack workspace with the user it acts as, an email account, a GitHub org's notifications. +They are listed here so the binding exists from the start, but they are wired up only when the conductor's Slack front end lands (§10, "Intake"). +A channel is a credentialed resource like any other, so it can be shared or separate by the same rules as §6.5. + +## 7. Reading across workstreams + +This is the owner's third question: one workstream sometimes needs to read another's projects for ideas, code or patterns. + +### 7.1 Share policy, set by the workstream being read + +| `share` | Another workstream may reference it… | +| --- | --- | +| `open` | freely. Picked in the new-session dialog or added mid-session; audited. | +| `ask` | with owner approval per grant, through the existing `approvalId` flow. | +| `closed` | never. The picker doesn't offer it, and `session.add_reference` is refused. | + +The policy belongs to the workstream being **read**, not the one reading, because the risk is the owner's obligation to that workstream's code. +Code you hold under someone else's terms (a client, an organisation) should be `closed` or `ask`, because copying it elsewhere can be an IP problem no matter how convenient it is. +Your own projects, research and personal setups are typically `open`. + +### 7.2 What a reference gives a session + +A reference names a workstream and, optionally, specific folders inside it: + +```ts +interface Reference { + workstream: string; + paths?: string[]; // absent = all the workstream's matched folders on this machine + grantedBy: string; // ZeroID subject that approved it + grantedAt: string; +} +``` + +It grants: +- **read** of those folders through file tools (`Read`, `Grep`, `Glob`); +- **recall** over that workstream's memory, filtered to those folders if `paths` is set. + +It never grants: +- write there; +- that workstream's MCP servers, env or accounts. + +You read Highflame's code, but you don't get Highflame's database. + +"Copy a pattern" therefore means: read it from the reference, write it in the home workdir. +The write lands in the home workstream and its git identity, which is exactly the provenance you want. + +### 7.3 Enforcement — say precisely what is enforced + +| Control | Status | +| --- | --- | +| Another workstream's MCP servers, env, accounts | **enforced**. Never given (§6.1–6.2). | +| File-tool read outside home + references | **enforced in the gate.** `canUseTool` path check, denied with a reason naming the workstream. | +| File-tool write outside the home workstream's folders | **enforced in the gate**, same place. | +| Recall outside home + references | **enforced** in the memory query. | +| `Bash` reading another workstream's files (`cat`, `rg`) | **prompted, not enforced.** `Bash` gets around every path scope; this is the same honest limit as conductor-design §3 and §9. | +| Qwen's read-only shell auto-allow (CLAUDE.md, "Known gap") | **not enforced.** A qwen session can read any file the daemon user can. Workstream isolation on qwen is credential isolation only. | + +The path check goes in the session's gate, not in `tool-safety.ts`, for the reason conductor-design open question #8 already settled: the classifier stays pure, and the gate makes the contextual decision. + +**The OS sandbox (#448) has landed since this was drafted, and it is the read fence.** It hides the home by default and shows `sandbox.projectRoots` (default `@parent`, the parent of the repository) read-only. +That default is exactly the wrong thing for workstreams: with `~/Workspace` as the parent, every other workstream's repositories are readable. +So W3 builds on the sandbox rather than beside it: +- a session's project roots are its home workstream's folders plus its references, never the bare `@parent`; +- every other workstream's matched folders under a shown root go into the session's hidden paths; +- the `Bash` row above becomes **enforced** for a sandboxed session, because a command runs inside the fence; the gate's path check stays for the file tools that run outside the sandbox (Claude native's, opencode's), exactly as `sandbox/host-file-tools.ts` already holds them to the sandbox's spec. +An unsandboxed session (`--sandbox off`) keeps the weaker rows as written. + +## 8. When configuration changes + +- The config has a **revision** (a hash of the `workstreams` block, so it is the same across restarts for the same config), and each binding records the revision it applied. +- **Narrowing** changes take effect at the **next turn boundary** of every affected live session, with a backend restart. That includes removing a server, removing a reference, and tightening `share`. + Waiting for a manual refresh would leave a revoked credential live, which is the wrong failure. +- **Widening** changes (adding a server, loosening `share`) take effect on the next refresh or the next new session, matching how the MCP session view works today ("what's applied changes only on `load()`"). +- **Deleting a workstream** re-homes its sessions to the default only if they have no running turn. Otherwise they are marked `orphaned` and refuse new turns until re-homed. Memory keeps its tag. +- **Changing match rules** never re-homes a live session. It affects only new sessions. The panel lists the sessions that would resolve differently. + +## 9. Federation + +Workstreams are owner configuration, so they live on the **hub** and travel to satellites the same way federation-design plans for other owner settings. +Two things make this work across machines: + +- **Remote rules are machine-independent.** `github.com/highflame-ai/*` means the same thing on every box. Path rules are expanded per machine (`~`), and a satellite can add machine-local path rules if its layout differs. +- **Secrets never travel.** `env` holds references, and each machine resolves them from its own secret store. + If a satellite lacks a secret, sessions of that workstream can't be **placed** on that machine. That gives federated dispatch a real placement rule: "Sentisum work runs only where the Sentisum token lives". + +The fleet board treats workstream the way federation-design §14.1 treats machine: **a badge and a filter, not the grouping.** + +## 10. The conductor + +The conductor is the one session whose job crosses workstreams, and this design keeps that without making it the leak. + +- **Home.** The conductor is homed in a reserved `conductor` workstream. It is given no workstream's servers and no workstream's env. + Its own capabilities (fleet, memory, notes, web) are unchanged. +- **Reading across workstreams.** `fleet_list`, `fleet_find` and `fleet_recall` see every workstream. + For a `closed` workstream they return **session cards, including the rolling summary, and status, but never episode text** (decided 2026-11-10: the summaries are what make "what's going on across my work" answerable). + Each tool gains a `workstream` filter, and each result carries its workstream id. + The conductor can tell you "the Sentisum review is stuck on Ankit, and the migration PR is waiting on CI". It can't quote Sentisum code or transcripts into another context. + Because summaries do carry specifics, the boundary that matters is the next point: what the conductor *knows* never reaches another workstream's egress without passing through a worker homed there and an approval you see. +- **Acting goes through a session homed in the target workstream.** This is conductor-design §8 ("the conductor decides; a narrowly-scoped child acts") with one more constraint: + every send-class action names a target workstream, and it runs in a worker **homed there**. + - That worker has only the target workstream's servers, credentials and memory. + - A Highflame Slack reply is drafted by a worker that has never seen a Sentisum episode. + - The approval card shows the workstream as well as recipient, repo and content. +- **`fleet_spawn`** takes `workstream` and follows §5. A conflicting spawn returns `workstream_conflict` to the conductor, and it has to choose instead of guessing. +- **Intake.** When the conductor's Slack and GitHub intake arrives (Slack front end, a later phase), each inbox source is bound to one workstream: Highflame Slack to `highflame`, Sentisum Slack to `sentisum`. + A signal is born with its workstream, and its reply worker inherits it. + +This is why workstreams belong in codeoid's core rather than in a personal layer on top. The conductor can only be safe across all of an owner's lines of work if the boundary exists below it. + +## 11. Rejected alternatives + +- **A workstream as a ZeroID project (tenant).** This gives real auth isolation, but the conductor would need a token per tenant to read across them, and the owner would need to log in per workstream. + Tenancy is for separating principals. One person's lines of work are one principal with several policies. +- **Regex-only matching.** It's error-prone for paths and doesn't follow a repo across machines. Globs plus remote rules do both. +- **Explicit choice always wins, silently.** That turns the picker into a way around every credential boundary. The chosen design lets it win only after a warning you confirm (§5.2). +- **The folder always wins, with no explicit choice.** Scratch folders and new projects have no folder yet, and "start a Dhishvari session in `/tmp`" is a real need. +- **A separate daemon per workstream.** This isolates fully but loses the conductor's whole view, shared memory infrastructure, and one approval queue. It also multiplies the mobile and Telegram front doors. + +## 12. Interfaces + +### Protocol (`packages/protocol`) + +| Message | Scope | Purpose | +| --- | --- | --- | +| `workstream.list` | `settings:read` | the items, a summary of resolved matches, conflicts, unassigned global servers | +| `workstream.upsert` / `workstream.delete` | `settings:write` | edit; validates globs, overlap and server names; bumps the revision | +| `workstream.resolve { workdir }` | `session:create` | what the picker shows: home, source, `matchedBy`, conflicts, allowed references | +| `session.create.workstream`, `session.create.references` | `session:create` | explicit choice (§5) | +| `session.set_workstream`, `session.add_reference`, `session.remove_reference` | `session:send` (+ approval per §7.1) | changes mid-session | +| `SessionInfo.workstream` | — | badge and filter on the fleet board, session list and Rust TUI mirror | + +Workstreams are a list of structured objects, so they get their own messages and their own panel, as accounts and MCP servers do. +They don't fit the scalar fields of the settings manifest (`settings/manifest.ts`). + +### Web UI + +- **Settings → Workstreams**, a list editor beside the **Accounts** and **MCP Servers** panels (`AccountsPanel.tsx` and `McpServersPanel.tsx` are the patterns): + - name, color, match rules with a **live preview** of the folders and current sessions each rule matches; + - an MCP server checklist drawn from the registry, plus a warning for unassigned global servers; + - an account per backend; default model and effort; secret references for env; + - the `claim` and `share` policies, with a one-line explanation of each next to the control. +- **New-session dialog**: a workstream picker, pre-filled from `workstream.resolve` as you choose the folder, with a "matched by …" hint. + An exclusive-claim conflict is shown inline with options (a) and (b), before you submit. There is a reference picker filtered by `share`. +- **Session header**: a workstream chip. Clicking it shows servers, references and "move to…". +- **Fleet board**: filtered by workstream and colored by workstream. +- **Mobile and Telegram approval cards** show the workstream badge beside the recipient, repo and content, so an approval is never made without knowing which line of work it acts in. + +### CLI + +```bash +codeoid workstream ls | show | add --path --remote | rm +codeoid workstream match # what would this folder resolve to, and why +codeoid new --workstream [--ref [:]]... +codeoid workstream move +``` + +## 13. Phases + +| Phase | Ships | Enforced by the end | +| --- | --- | --- | +| **W0** | schema and config, resolver, `codeoid workstream match`, binding in session meta, `SessionInfo.workstream`. **No behavior change.** | nothing. Visibility only, to check the rules against real folders | +| **W1** | per-workstream MCP servers (all three sharing kinds in §6.5), env, git identity and `skillDirs`; `extends`; `isolateGlobalServers`; Claude `deniedMcpServers` by URL; `codeoid workstream import` | **credentials**, the part the owner needs first | +| **W2** | Web UI: settings panel, picker, conflict flow, chip, fleet filter; CLI `new --workstream` | — | +| **W3** | memory `workstream_id`, scoped recall, references with `share` policy, path gate for file tools | recall and file-tool reads | +| **W4** | conductor: reserved home, workstream-aware fleet tools, workers homed in the target workstream for send-class actions | the conductor's egress | +| **W5** | federation: placement by secret availability; remote-rule resolution on satellites | placement | + +**Moving off today's setup.** `codeoid workstream import` reads `~/.claude.json` and the git remotes of folders under the workspace root, then *proposes* workstreams: groups of folders by remote owner, with the global servers each group seems to use. +You review and edit the proposal in the panel, save it, and only then turn on `isolateGlobalServers`. +Nothing is enforced until you flip that switch, so the import can be tried with no risk. + +How this fits [conductor-build-plan.md](./conductor-build-plan.md): +- **W0–W1 come before P5 (front doors).** Once the conductor is reachable from a phone, its sessions need the credential boundary. +- **W4 is a prerequisite of P6** ("act on behalf"). Slack and email egress must be workstream-homed from the first send. +- **W3's path gate is the same code site as the R6 path-scoped write** (conductor-design §3). Build them together. + +## 14. Open questions (grill seeds) + +**Settled 2026-11-10:** + +1. ~~**Exclusive-claim conflict.**~~ Warn and confirm, via the approval flow. Per-workstream `allowOverride: false` for a hard refusal (§5.2). +2. ~~**Conductor and `closed` summaries.**~~ The conductor sees card summaries and status. It never sees episode text (§10). +7. ~~**Default workstream.**~~ Optional. With none set, unmatched folders must pick (§5.1). +3. ~~**A reference after a `share` tightening.**~~ The reference is removed at the next turn (§8). The session is also tagged "has read from *X*" for its lifetime, so a later fork or move into another workstream follows *X*'s **new** policy, not the policy at the time of the read. +4. ~~**`Bash` in a session with references.**~~ Approval is required only for `Bash` commands that name a path outside the home workstream and its references. Other `Bash` calls are unaffected. It is a cheap check, not a fence (§7.3). +6. ~~**Forks of another organisation's repo under your own account.**~~ Remote rules also check the `upstream` remote. A match through `upstream` alone counts as `claim: suggest`, never `exclusive`: you see where the code came from, but you are not blocked. + +**Still open:** none from the first round. The next round belongs to `/grill-feature` against W1's credential model (§6.5 case 2 and the OAuth credential slot). + +## 15. Deferred (backlog) + +- **Per-workstream spend caps** (was open question 5). A daily cap per workstream plus an overall cap, so an unattended loop in one workstream can't use up the subscription quota another needs. + Deferred on 2026-11-10. It stays a prerequisite of unattended routines across workstreams, the same position as the amended R5 in conductor-design. +- **A real read fence** (§7.3): a per-session OS sandbox with the home and reference folders bound in. Decide together with conductor-design open question #10. +- **`regex:` match rules** (§4), only if a real case needs them. diff --git a/e2e/harness.ts b/e2e/harness.ts index 4a1d081e..3214ceb2 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -100,6 +100,16 @@ export class Codeoid { git("commit", "-qm", "init"); } + /** + * Merge `patch` into the daemon's config.json (top-level keys replaced). + * Takes effect at the next start: call `restart()` after. + */ + configure(patch: Record): void { + const path = join(this.configDir, "codeoid", "config.json"); + const current = JSON.parse(readFileSync(path, "utf8")) as Record; + writeFileSync(path, JSON.stringify({ ...current, ...patch })); + } + /** Replace the scripted backend's rules; read again on every turn. */ setScript(rules: ScriptRule[]): void { writeFileSync(this.scriptPath, JSON.stringify({ rules })); diff --git a/e2e/specs/workstreams.spec.ts b/e2e/specs/workstreams.spec.ts new file mode 100644 index 00000000..ccaa1c69 --- /dev/null +++ b/e2e/specs/workstreams.spec.ts @@ -0,0 +1,45 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "../harness"; + +// Workstreams W0 (docs/workstreams-design.md): a session's home is decided at +// create by its folder (or an explicit choice), shown, refused past an +// exclusive claim, and kept across a restart. +test("a session's workstream comes from its folder, an exclusive folder refuses another, and the home survives a restart", async ({ codeoid }) => { + const scratch = join(codeoid.root, "scratch"); + mkdirSync(scratch, { recursive: true }); + codeoid.configure({ + workstreams: { + items: [ + { id: "acme", name: "Acme Corp", claim: "exclusive", match: { paths: [codeoid.workdir] } }, + { id: "research", match: { paths: ["~/never"] } }, + ], + default: "research", + }, + }); + await codeoid.restart(); + + const ls = await codeoid.cliOk("workstream", "ls"); + expect(ls).toContain("acme"); + expect(ls).toContain("exclusive"); + expect(ls).toMatch(/research.*default/); + + const match = await codeoid.cliOk("workstream", "match", codeoid.workdir); + expect(match).toContain(`Workstream: acme — matched by ${codeoid.workdir}`); + + const conflict = await codeoid.cliOk("workstream", "match", codeoid.workdir, "--workstream", "research"); + expect(conflict).toContain('belongs to "acme" (exclusive)'); + + expect(await codeoid.cliOk("new", "in-acme", codeoid.workdir)).toContain("workstream: Acme Corp (acme) — matched by"); + expect(await codeoid.cliOk("new", "loose", scratch)).toContain("workstream: research — the default"); + + const refused = await codeoid.cli("new", "wrong-home", codeoid.workdir, "--workstream", "research"); + expect(refused.code).not.toBe(0); + expect(refused.output).toContain('This folder belongs to workstream "acme"'); + + await codeoid.restart(); + const after = await codeoid.cliOk("ls"); + expect(after).toContain("workstream: Acme Corp (acme) — matched by"); + expect(after).toContain("workstream: research — the default"); + expect(after).not.toContain("wrong-home"); +}); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 4b1a8d0a..b4778581 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -4,6 +4,7 @@ export * from "./settings.js"; export * from "./backend-login.js"; export * from "./skill-grant.js"; export * from "./worktree-trust.js"; +export * from "./workstreams.js"; export * from "./turns.js"; export * from "./approvals.js"; export * from "./compare.js"; diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 2c586a89..0592839e 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -163,6 +163,8 @@ const samples: { [T in ClientTypes]: Extract } = { "mcp.adopt": { type: "mcp.adopt", id: "r93", server: "notion", sessionId: "s1" }, "skill.grant": { type: "skill.grant", id: "r93", workdir: "/tmp/w", command: "./probe.sh", allowed: true }, "worktree.trust": { type: "worktree.trust", id: "wt1", dir: "/tmp/repo", recipeHash: "0123456789abcdef0123456789abcdef01234567" }, + "workstream.list": { type: "workstream.list", id: "ws1" }, + "workstream.resolve": { type: "workstream.resolve", id: "ws2", workdir: "/tmp/repo", workstream: "acme" }, "session.turns": { type: "session.turns", id: "r94", sessionId: "s1" }, "session.approvals": { type: "session.approvals", id: "r94a", sessionId: "s1" }, "claude.accounts.list": { type: "claude.accounts.list", id: "ca1" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 6a1b7ecb..480f4681 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -29,6 +29,8 @@ const pathField = z.string().max(LIMITS.PATH_MAX); const nameField = z.string().min(1).max(LIMITS.NAME_MAX); const base = { id: idField }; +/** A workstream id, bounded only: a malformed one must PARSE so the daemon answers `workstream_unknown`. */ +const workstreamIdField = z.string().min(1).max(64); // ── Collaboration ───────────────────────────────────────────────────────────── @@ -163,6 +165,12 @@ export const sessionCreateSchema = z.object({ packRole: z.string().max(64).optional(), /** OS sandbox (#448); absent = the daemon's config. */ sandbox: sessionSandboxSettingSchema.optional(), + /** + * Home workstream (docs/workstreams-design.md §5). A bounded string, not an + * enum of configured ids: the frame must PARSE so the daemon can answer with + * `workstream_unknown` rather than an opaque schema rejection. + */ + workstream: workstreamIdField.optional(), }); export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") }); @@ -1083,6 +1091,14 @@ export const worktreeTrustSchema = z.object({ .optional(), }); +export const workstreamListSchema = z.object({ ...base, type: z.literal("workstream.list") }); +export const workstreamResolveSchema = z.object({ + ...base, + type: z.literal("workstream.resolve"), + workdir: pathField.min(1), + workstream: workstreamIdField.optional(), +}); + export const skillGrantSchema = z.object({ ...base, type: z.literal("skill.grant"), @@ -1316,6 +1332,8 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ backendLoginWaitSchema, skillGrantSchema, worktreeTrustSchema, + workstreamListSchema, + workstreamResolveSchema, sessionTurnsSchema, sessionApprovalsSchema, claudeAccountsListSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index a59ba574..a40364b8 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -44,6 +44,14 @@ import type { } from "./accounts.js"; import type { SkillGrantMsg, SkillGrantResultMsg } from "./skill-grant.js"; import type { WorktreeTrustMsg, WorktreeTrustResultMsg } from "./worktree-trust.js"; +import type { + SessionWorkstreamInfo, + WorkstreamListMsg, + WorkstreamListResultMsg, + WorkstreamResolution, + WorkstreamResolveMsg, + WorkstreamResolveResultMsg, +} from "./workstreams.js"; import type { SessionRewindMsg, SessionRewindResultMsg, SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js"; import type { SessionApprovalsMsg, SessionApprovalsResultMsg } from "./approvals.js"; import type { @@ -552,6 +560,11 @@ export interface SessionInfo { * sessions should set this too, so that refusal covers it without new code. */ managedBy?: "pipeline" | "routine"; + /** + * Home workstream (docs/workstreams-design.md). Absent on a daemon without + * workstreams configured, and on older daemons. + */ + workstream?: SessionWorkstreamInfo; /** * A prompt has been accepted but its turn has not started yet (hooks, the * turn checkpoint, identity registration are still running). The session can @@ -1342,6 +1355,8 @@ export type ClientMessage = | BackendLoginWaitMsg | SkillGrantMsg | WorktreeTrustMsg + | WorkstreamListMsg + | WorkstreamResolveMsg | SessionTurnsMsg | SessionApprovalsMsg | ClaudeAccountsListMsg @@ -1574,6 +1589,14 @@ export interface SessionCreateMsg extends BaseClientMsg { * (read-only). Requires `pack`; rejected with `collaboration` (a collab's * roles each carry their own envelope from the pack). */ packRole?: string; + /** + * Home workstream (docs/workstreams-design.md §5). Absent = the folder's, + * else the configured default. A choice that differs from a workstream + * holding an exclusive claim on the folder is refused with + * `detail.kind: "workstream_conflict"`; an unknown one with + * `"workstream_unknown"`. + */ + workstream?: string; } /** @@ -3249,6 +3272,8 @@ export type DaemonMessage = | BackendLoginWaitResultMsg | SkillGrantResultMsg | WorktreeTrustResultMsg + | WorkstreamListResultMsg + | WorkstreamResolveResultMsg | SessionTurnsResultMsg | SessionApprovalsResultMsg | ClaudeAccountsResultMsg @@ -3358,7 +3383,16 @@ export interface ResponseErrorMsg { * refused because the branch's head is another commit now. `head` is that * commit's full id: pin to it, after looking at it, to vouch again. */ -export type ResponseErrorDetail = { kind: "head_moved"; branch: string; head: string; pinned: string }; +export type ResponseErrorDetail = + | { kind: "head_moved"; branch: string; head: string; pinned: string } + /** + * `workstream_conflict`: `session.create` chose a workstream other than the + * one holding an exclusive claim on the folder (docs/workstreams-design.md + * §5.2). `resolution` says what the folder resolves to and why. + */ + | { kind: "workstream_conflict"; resolution: WorkstreamResolution } + /** `workstream_unknown`: the chosen workstream isn't configured (or is a base). */ + | { kind: "workstream_unknown"; workstream: string }; export type ErrorCode = | "unauthorized" diff --git a/packages/protocol/src/workstreams.ts b/packages/protocol/src/workstreams.ts new file mode 100644 index 00000000..30178f76 --- /dev/null +++ b/packages/protocol/src/workstreams.ts @@ -0,0 +1,130 @@ +/** + * Workstreams (docs/workstreams-design.md): named lines of work that decide + * which servers, credentials, accounts and models a session gets. + * + * A session has exactly one home workstream, chosen at create (explicitly, or + * by its folder) and kept: resume re-checks it against the config, never + * re-matches the folder. + */ + +/** How a session got its home. */ +export type WorkstreamSource = + /** Chosen at create (`session.create.workstream`). */ + | "explicit" + /** Its folder matched a rule. */ + | "matched" + /** Nothing matched; the configured default. */ + | "default" + /** Chosen past another workstream's exclusive claim on the folder, confirmed (§5.2). */ + | "override" + /** A session from before workstreams, resolved once by its folder on resume. */ + | "migrated" + /** A home the daemon assigns itself (the conductor's). */ + | "reserved" + /** Workstreams are configured, but nothing matched and there's no default. */ + | "none"; + +/** Where a session's home came from, as `SessionInfo` carries it. */ +export interface SessionWorkstreamInfo { + /** The home workstream's id. Absent = no home (`source: "none"`). */ + id?: string; + /** Display name and color, as configured, when the workstream still exists. */ + name?: string; + color?: string; + source: WorkstreamSource; + /** The rule that matched, for display. */ + matchedBy?: string; + /** The home was deleted from the config: the session needs a new one. */ + orphaned?: boolean; +} + +export interface WorkstreamServerInfo { + server: string; + credential?: string; + /** The workstream that contributed it: itself, or a base it extends. */ + from: string; +} + +/** A workstream as configured, bases merged in. Secret values never appear: env lists names only. */ +export interface WorkstreamSummary { + id: string; + name: string; + color?: string; + abstract: boolean; + extends: string[]; + claim: "exclusive" | "suggest"; + allowOverride: boolean; + share: "open" | "ask" | "closed"; + match: { paths: string[]; remotes: string[] }; + servers: WorkstreamServerInfo[]; + accounts: Record; + defaults: { provider?: string; model?: string; effort?: string }; + /** Env var names it sets (values are credential references, resolved by the daemon). */ + env: string[]; + git?: { name?: string; email?: string }; + skillDirs: string[]; +} + +export interface WorkstreamMatchInfo { + workstream: string; + rule: string; + kind: "path" | "remote"; + via?: "origin" | "upstream"; + claim: "exclusive" | "suggest"; +} + +/** What a folder (and an optional explicit choice) resolves to. */ +export interface WorkstreamResolution { + /** The home it would get. Absent = none. */ + home?: string; + source: WorkstreamSource; + matchedBy?: string; + /** Every workstream whose rules match the folder, best first. */ + candidates: WorkstreamMatchInfo[]; + /** Workstreams tied for best: no home is picked until one is chosen. */ + ambiguous?: string[]; + /** + * The explicit choice differs from the workstream holding an exclusive + * claim on the folder (§5.2). `overridable: false` = that workstream set + * `allowOverride: false`. + */ + conflict?: { folder: string; chosen: string; overridable: boolean }; +} + +// ── Messages (client → daemon) ──────────────────────────────────────────────── + +/** The configured workstreams. Needs `settings:read`. */ +export interface WorkstreamListMsg { + type: "workstream.list"; + id: string; +} + +/** What a folder would resolve to, for the new-session picker. Needs `session:create`. */ +export interface WorkstreamResolveMsg { + type: "workstream.resolve"; + id: string; + workdir: string; + /** An explicit choice to check against the folder. */ + workstream?: string; +} + +// ── Messages (daemon → client) ──────────────────────────────────────────────── + +export interface WorkstreamListResultMsg { + type: "workstream.list.result"; + requestId: string; + /** Changes with every change to the `workstreams` block. */ + revision: string; + isolateGlobalServers: boolean; + default?: string; + workstreams: WorkstreamSummary[]; +} + +export interface WorkstreamResolveResultMsg { + type: "workstream.resolve.result"; + requestId: string; + /** The folder as the daemon read it (`~` expanded). */ + workdir: string; + revision: string; + resolution: WorkstreamResolution; +} diff --git a/src/cli.ts b/src/cli.ts index 265b1b6b..65dbdd13 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -57,6 +57,7 @@ import { TerminalClient } from "./terminal/client.js"; import { bedrockFromSettings, formatAccounts, formatClaudeAccounts, parseHeaderFlags, parseSettingFlags } from "./terminal/claude-accounts.js"; import { formatEndpoints, importPiModels, parseModelFlags, parseTokens } from "./terminal/endpoints.js"; import type { ClientMessage, DaemonMessage, EffortLevel, SessionInfo, SessionSandboxSetting } from "./protocol/types.js"; +import { sanitizeTerminalOutput } from "./tui/ansi/codes.js"; import { parseEffort } from "@highflame/codeoid-core"; import { mcpConfig, mcpHeaders } from "./terminal/mcp-control-cli.js"; import { cleartextOffHost, controlUrl, MCP_SCOPE_PRESETS, TokenError } from "./terminal/token.js"; @@ -1084,6 +1085,10 @@ program .option("--add-dir ", "Another directory its sandboxed commands may write (a second repository), repeatable; treated as a workspace. Needs session:approve (docs/sandbox.md, Working across repositories).", (v: string, p: string[] = []) => [...p, v], [] as string[]) .option("--project-root ", "Where its sandboxed commands may read other repositories, read-only, repeatable; replaces sandbox.projectRoots (@parent = the repository's parent, the default).", (v: string, p: string[] = []) => [...p, v], [] as string[]) .option("--no-project-roots", "No project root: its commands read only the workspace (and the toolchains the sandbox shows)") + .option( + "--workstream ", + "Home workstream (docs/workstreams-design.md): decides the session's servers, credentials and defaults. Default: the folder's (`codeoid workstream match `), else the configured default.", + ) .option( "--pack ", "Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.", @@ -1125,6 +1130,7 @@ program addDir: string[]; projectRoot: string[]; projectRoots?: boolean; + workstream?: string; }, ) => { const config = loadConfig(); @@ -1197,6 +1203,7 @@ program packRole: opts.packRole, collaboration, ...(sandbox ? { sandbox } : {}), + ...(opts.workstream ? { workstream: opts.workstream } : {}), }); client.disconnect(); }, @@ -1604,6 +1611,129 @@ worktreeCmd } }); +// ── Workstreams (docs/workstreams-design.md) ──────────────────────────────── + +const workstreamCmd = program + .command("workstream") + .description("Named lines of work: which folders belong to which, and what their sessions get (docs/workstreams-design.md)"); + +/** Daemon-supplied text, made safe for the terminal. */ +const clean = (s: string): string => sanitizeTerminalOutput(s); + +/** Run one request against the daemon and hand the reply to `show`; exits non-zero on an error reply. */ +async function workstreamRequest( + msg: ClientMessage, + want: T, + show: (reply: Extract) => void, +): Promise { + const client = new TerminalClient(loadConfig()); + await client.connect(); + try { + const reply = await client.request(msg); + if (reply.type !== want) { + console.error(`Error: ${clean(reply.type === "response.error" ? reply.error : `unexpected reply ${reply.type}`)}`); + process.exitCode = 1; + return; + } + show(reply as Extract); + } finally { + client.disconnect(); + } +} + +workstreamCmd + .command("ls") + .description("List the configured workstreams") + .option("--json", "print the daemon's reply as JSON") + .action(async (o: { json?: boolean }) => { + await workstreamRequest({ type: "workstream.list", id: randomUUID() }, "workstream.list.result", (r) => { + if (o.json) return void console.log(JSON.stringify(r, null, 2)); + if (r.workstreams.length === 0) { + console.log("No workstreams configured. Add a `workstreams` block to config.json (docs/workstreams-design.md §4)."); + return; + } + for (const w of r.workstreams) { + const tags = [w.abstract ? "base" : w.claim, `share:${w.share}`, ...(r.default === w.id ? ["default"] : [])]; + const rules = w.match.paths.length + w.match.remotes.length; + console.log( + `${clean(w.id).padEnd(20)} ${clean(w.name).padEnd(24)} ${tags.join(", ").padEnd(32)} ${rules} rule${rules === 1 ? "" : "s"}, ${w.servers.length} server${w.servers.length === 1 ? "" : "s"}`, + ); + } + console.log(`\nServer isolation: ${r.isolateGlobalServers ? "on" : "off — every session still gets every server"} · revision ${r.revision}`); + }); + }); + +workstreamCmd + .command("show ") + .description("Show one workstream, its bases merged in") + .option("--json", "print it as JSON") + .action(async (id: string, o: { json?: boolean }) => { + await workstreamRequest({ type: "workstream.list", id: randomUUID() }, "workstream.list.result", (r) => { + const w = r.workstreams.find((x) => x.id === id); + if (!w) { + console.error(`No workstream "${id}" — see \`codeoid workstream ls\`.`); + process.exitCode = 1; + return; + } + if (o.json) return void console.log(JSON.stringify(w, null, 2)); + const line = (k: string, v: string): void => console.log(`${k.padEnd(14)} ${clean(v)}`); + line("id", w.id); + line("name", w.name); + if (w.abstract) line("kind", "base (no folders or sessions of its own)"); + if (w.extends.length > 0) line("extends", w.extends.join(", ")); + line("claim", w.claim === "exclusive" ? `exclusive${w.allowOverride ? "" : " (no override)"}` : "suggest"); + line("share", w.share); + for (const p of w.match.paths) line("path", p); + for (const rr of w.match.remotes) line("remote", rr); + for (const s of w.servers) line("server", `${s.server}${s.credential ? ` (credential ${s.credential})` : ""}${s.from !== w.id ? ` — from ${s.from}` : ""}`); + for (const [backend, account] of Object.entries(w.accounts)) line("account", `${backend}: ${account}`); + for (const [k, v] of Object.entries(w.defaults)) if (v) line("default", `${k}: ${v}`); + for (const e of w.env) line("env", e); + if (w.git?.name || w.git?.email) line("git", [w.git.name, w.git.email && `<${w.git.email}>`].filter(Boolean).join(" ")); + for (const d of w.skillDirs) line("skills", d); + }); + }); + +workstreamCmd + .command("match [dir]") + .description("Which workstream a folder belongs to, and why — what a new session there would get") + .option("--workstream ", "check an explicit choice against the folder") + .option("--json", "print the resolution as JSON") + .action(async (dir: string | undefined, o: { workstream?: string; json?: boolean }) => { + const target = dir ? dirFlag(dir) : process.cwd(); + await workstreamRequest( + { type: "workstream.resolve", id: randomUUID(), workdir: target, ...(o.workstream ? { workstream: o.workstream } : {}) }, + "workstream.resolve.result", + (r) => { + if (o.json) return void console.log(JSON.stringify(r, null, 2)); + const res = r.resolution; + console.log(`Folder: ${clean(r.workdir)}`); + if (res.home) { + const how = res.source === "matched" ? `matched by ${res.matchedBy}` : res.source === "default" ? "the default (no rule matched)" : res.source; + console.log(`Workstream: ${clean(res.home)} — ${clean(how)}`); + } else if (res.ambiguous) { + console.log(`Workstream: none — ${clean(res.ambiguous.join(" and "))} both match. Choose one with --workstream, or make one rule more specific.`); + } else { + console.log("Workstream: none — no rule matches and no default is set."); + } + if (res.conflict) { + const c = res.conflict; + console.log( + `\n⚠ This folder belongs to "${clean(c.folder)}" (exclusive). A session here in "${clean(c.chosen)}" would ${ + c.overridable ? "need a confirmed override" : "be refused: that workstream allows no override" + }.`, + ); + } + if (res.candidates.length > 0) { + console.log("\nMatching rules, best first:"); + for (const m of res.candidates) { + console.log(` ${clean(m.workstream).padEnd(20)} ${m.kind === "remote" ? `remote ${clean(m.rule)}${m.via === "upstream" ? " (upstream)" : ""}` : clean(m.rule)} [${m.claim}]`); + } + } + }, + ); + }); + // ── Outward control plane (/mcp/control — docs/mcp-control.md) ───────────── const mcp = program diff --git a/src/config.ts b/src/config.ts index ec97dcfd..96999108 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,7 @@ import { DEFAULT_HOME_READABLE } from "./daemon/sandbox/home-readable.js"; import { validAllowedHost } from "./daemon/sandbox/hosts.js"; import { PROJECT_ROOT_PARENT, projectRootProblem } from "./daemon/sandbox/dir-rules.js"; import { worktreeRootProblem } from "./daemon/sandbox/worktrees.js"; +import { workstreamsSchema, type WorkstreamsConfig } from "./daemon/workstreams/schema.js"; import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { join, isAbsolute, resolve, dirname } from "node:path"; @@ -1370,6 +1371,8 @@ const RootSchema = z.object({ pipeline: PipelineSchema, providers: ProvidersSchema, mcpServers: McpServersSchema, + /** Named lines of work (docs/workstreams-design.md). */ + workstreams: workstreamsSchema, hooks: HooksSchema, embed: EmbedSchema, push: PushSchema, @@ -1740,6 +1743,12 @@ export interface CodeoidConfig { mcpServers?: Record; /** OAuth for remote MCP servers — see `RootSchema.mcpOAuth`. */ mcpOAuth?: { redirectBaseUrl?: string }; + /** + * Workstreams (docs/workstreams-design.md). Optional in the type so + * hand-built test configs stay minimal; loadConfig always populates it + * (schema default: none, isolation off). + */ + workstreams?: WorkstreamsConfig; /** * Daemon-native hooks — dispatched at Session's seams for every backend. * Optional in the type so hand-built test configs stay minimal; @@ -2123,6 +2132,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { }, mcpServers: parsed.mcpServers, mcpOAuth: parsed.mcpOAuth, + workstreams: parsed.workstreams, hooks: parsed.hooks, embed: parsed.embed, push: parsed.push, diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index dde7f573..9e243570 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -9,6 +9,8 @@ */ import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; +import { WorkstreamService, type WorkstreamBinding } from "./workstreams/service.js"; +import type { FolderReader } from "./workstreams/folder.js"; import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; @@ -269,6 +271,19 @@ function normalizeWorkdir(input: string): string | null { } } +/** + * A workdir as the caller wrote it, `~` expanded and made absolute — not + * canonicalised: matched alongside the canonical path, so a workstream rule + * written through a symlink matches the folder reached through it. + */ +function givenPath(input: string): string | undefined { + const raw = (input ?? "").trim(); + if (!raw) return undefined; + if (raw === "~") return homedir(); + if (raw.startsWith("~/")) return resolve(homedir(), raw.slice(2)); + return resolve(raw); +} + /** Eager-resume bounds. Resume runs before the daemon listens — so an * unbounded resume can block startup or OOM. Cap to the newest-N sessions and * stop past a deadline (applied WITHIN each transcript parse too, not just @@ -561,6 +576,8 @@ export class SessionManager { */ #modelLimits = new Map(); #config?: CodeoidConfig; + /** Workstreams (docs/workstreams-design.md): decides a session's home; the session keeps it. */ + #workstreams: WorkstreamService; /** * `config.worktree.setup`, live: `codeoid worktree trust` changes it * without a restart (and writes it through to config.json). @@ -705,6 +722,8 @@ export class SessionManager { _testEndpointFetch?: typeof fetch; /** The OS sandbox providers (#448, sandbox/registry.ts). Absent = sessions aren't sandboxed. */ sandboxRegistry?: SandboxRegistry; + /** Test-only: how folders are read for workstream matching (a fake home, an ssh config). */ + _testFolderReader?: FolderReader; }, ) { this.#store = store; @@ -714,6 +733,7 @@ export class SessionManager { this.#rateLimiter = rateLimiter ?? new RateLimiter(); this.#memory = memory; this.#config = opts?.config; + this.#workstreams = new WorkstreamService(opts?.config?.workstreams, opts?._testFolderReader); this.#sandboxRegistry = opts?.sandboxRegistry; if (opts?.config?.worktree?.setup) this.#worktreeSetupPolicy = opts.config.worktree.setup; this.#compressionRegistry = opts?.compressionRegistry; @@ -1037,6 +1057,20 @@ export class SessionManager { for (const m of allMetas) { if (m.collaboration) goalConfigs.set(m.sessionId, m.collaboration); } + // Every session's home workstream, from EVERY meta (a fork's parent may be + // outside the resume window), decided on demand for the sessions resumed: + // validated, failing closed (G5). No folder is read here. + const resumeHomes = this.#workstreams.forResume( + allMetas.map((m) => ({ + sessionId: m.sessionId, + workdir: m.workdir, + ...(m.role ? { role: m.role } : {}), + stored: (m as { workstream?: unknown }).workstream, + ...((m.forkedFrom?.sessionId ?? m.collaborationRole?.parentSessionId) + ? { parentId: m.forkedFrom?.sessionId ?? m.collaborationRole?.parentSessionId } + : {}), + })), + ); const deadline = Date.now() + RESUME_DEADLINE_MS; const lazyLoad = this.#config?.session?.lazyLoad ?? true; let resumed = 0; @@ -1064,7 +1098,11 @@ export class SessionManager { if (child.orphaned) orphanedChildren++; else resumedChildren++; } + // Home workstream (G5), decided for every meta up front (forks and + // children inherit their parent's); persisted once resume succeeded. + const resumedWorkstream = resumeHomes(meta.sessionId); const session = new Session({ + ...this.#workstreamOpts(resumedWorkstream.binding), name: meta.sessionName, // Heal a workdir persisted with a literal `~` or one that has since // moved — expand/validate it so the SDK can launch. Falls back to @@ -1183,6 +1221,12 @@ mcpHub: this.#mcpHub, if (loadNow || !lazyLoad || meta.backingFresh) await session.ensureHydrated({ deadlineAt: deadline, atResume: true }); this.#sessions.set(session.id, session); + // Only now, once resume succeeded, and only the one field: everything + // else in the meta stays exactly as it was on disk (a backend that's + // gone keeps its id there, as before workstreams). + if (resumedWorkstream.persist && resumedWorkstream.binding) { + void this.#transcriptStore.patchMeta(session.id, { workstream: resumedWorkstream.binding }).catch(() => {}); + } // Track a resumed child's mount so teardown revokes it. Without this a // restart leaks one still-valid blackboard credential per child on // every boot, and destroying the goal would not revoke them. @@ -1595,6 +1639,10 @@ mcpHub: this.#mcpHub, return this.#skillGrant(msg, auth); case "worktree.trust": return this.#worktreeTrust(msg, auth); + case "workstream.list": + return this.#workstreamList(msg, auth); + case "workstream.resolve": + return this.#workstreamResolve(msg, auth); case "mcp.oauth.begin": return this.#mcpOAuthBegin(msg, auth); case "mcp.oauth.complete": @@ -1917,6 +1965,7 @@ mcpHub: this.#mcpHub, const problem = this.#loopWorktreeProblem(init.workdir); if (problem) throw new Error(problem); const session = new Session({ + ...this.#workstreamOpts(this.#workstreamForFolder(init.workdir)), name: init.name, workdir: init.workdir, auth, @@ -2238,6 +2287,29 @@ mcpHub: this.#mcpHub, } } + /** Renders a binding for SessionInfo against the live config. */ + readonly #describeWorkstream = (binding: WorkstreamBinding | undefined) => this.#workstreams.info(binding); + + /** The workstream options every Session this manager builds gets (docs/workstreams-design.md). */ + #workstreamOpts(binding: WorkstreamBinding | undefined): { workstream?: WorkstreamBinding; describeWorkstream: (b: WorkstreamBinding | undefined) => ReturnType } { + return { ...(binding ? { workstream: binding } : {}), describeWorkstream: this.#describeWorkstream }; + } + + /** + * The home of a session the daemon starts itself in `workdir` (an import, a + * loop attempt, a routine run, a dispatch worker): its folder's, else the + * default. Never refused: there is no explicit choice to conflict with. + */ + #workstreamForFolder(workdir: string): WorkstreamBinding | undefined { + try { + const r = this.#workstreams.forCreate(workdir); + return r.ok ? r.binding : undefined; + } catch (err) { + console.warn(`[codeoid] workstream for ${workdir}: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } + /** * The model hooks every Session this manager builds gets. One object, spread * at each construction site: pasting the lambdas into all eight meant a @@ -3294,6 +3366,48 @@ mcpHub: this.#mcpHub, return { type: "skill.grant.result", requestId: msg.id, workdir, command, allowed: msg.allowed }; } + /** The configured workstreams, bases merged in (docs/workstreams-design.md). Secret values never leave: env is names only. */ + #workstreamList(msg: Extract, auth: AuthContext): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_READ)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: settings:read", code: "forbidden" }; + } + const ws = this.#workstreams; + return { + type: "workstream.list.result", + requestId: msg.id, + revision: ws.revision, + isolateGlobalServers: ws.isolateGlobalServers, + ...(ws.defaultId ? { default: ws.defaultId } : {}), + workstreams: ws.summaries(), + }; + } + + /** + * What a folder resolves to (and whether an explicit choice conflicts), for + * the new-session picker. Gated like a create: it answers the question a + * create would, and nothing more (no remote URL, only which rule matched). + */ + #workstreamResolve(msg: Extract, auth: AuthContext): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: session:create", code: "forbidden" }; + } + const workdir = normalizeWorkdir(msg.workdir); + if (!workdir) { + return { type: "response.error", requestId: msg.id, error: `Working directory not found: ${msg.workdir}`, code: "invalid_request" }; + } + const problem = msg.workstream !== undefined ? this.#workstreams.choiceProblem(msg.workstream) : undefined; + if (problem && msg.workstream !== undefined) { + return { type: "response.error", requestId: msg.id, error: problem, code: "invalid_request", detail: { kind: "workstream_unknown", workstream: msg.workstream } }; + } + return { + type: "workstream.resolve.result", + requestId: msg.id, + workdir, + revision: this.#workstreams.revision, + resolution: this.#workstreams.resolve(workdir, msg.workstream, givenPath(msg.workdir)), + }; + } + /** * Trust (or stop trusting) a repository's worktree setup recipe, pinned to * its git blob — direnv-allow style (protocol/worktree-trust.ts). Needs @@ -4389,6 +4503,41 @@ mcpHub: this.#mcpHub, warnings.push(m); console.warn(`[codeoid] ${m}`); }; + + // Home workstream (docs/workstreams-design.md §5): an explicit choice, else + // the folder's, else the default. Refused before anything is built. + const givenWorkdir = givenPath(msg.workdir); + const homing = this.#workstreams.forCreate(workdir, msg.workstream, givenWorkdir); + if (homing.ok && msg.workstream !== undefined && auth.delegationDepth > 0) { + // A delegated token may confirm the folder's own home, never pick + // another: the home will decide credentials (W1), as an account choice + // does (which needs settings:write, above). + const folderHome = this.#workstreams.resolve(workdir, undefined, givenWorkdir).home; + if (folderHome !== msg.workstream) { + this.#store.audit(auth.sub, "session.workstream.denied", "", `workstream=${msg.workstream} folder=${folderHome ?? "none"} reason=delegated`); + return { + type: "response.error", + requestId: msg.id, + error: `A delegated token can't choose workstream "${msg.workstream}" for this folder (its own is ${folderHome ? `"${folderHome}"` : "none"}).`, + code: "forbidden", + }; + } + } + if (!homing.ok) { + return { + type: "response.error", + requestId: msg.id, + error: homing.error, + code: "invalid_request", + detail: + homing.kind === "workstream_unknown" + ? { kind: "workstream_unknown", workstream: homing.workstream } + : { kind: homing.kind, resolution: homing.resolution }, + }; + } + for (const w of homing.warnings) warn(w); + const createWorkstream = homing.binding; + if (msg.collaboration) { let collabConfig = msg.collaboration; if (msg.pack) { @@ -4651,6 +4800,7 @@ mcpHub: this.#mcpHub, : undefined; const session = new Session({ + ...this.#workstreamOpts(createWorkstream), name: msg.name, workdir, auth, @@ -5054,6 +5204,7 @@ mcpHub: this.#mcpHub, child, ); const childSession = new Session({ + ...this.#workstreamOpts(this.#workstreams.inherit(parent.workstream)), name: childSessionName(parent.name, child), workdir: parent.workdir, auth, @@ -5372,6 +5523,34 @@ mcpHub: this.#mcpHub, } const looped = this.#loopWorktreeRefusal(msg.id, bound); if (looped) return looped; + // A fork into a folder of its own keeps the parent's home — so that + // folder must not be another workstream's exclusive one, exactly as + // for a create choosing that home there (W0 audit). + if (parent.workstream?.id && this.#workstreams.configured) { + // A delegated token may not carry a home into a folder that isn't + // that home's — the same rule as choosing one at create. + if (auth.delegationDepth > 0) { + const folderHome = this.#workstreams.resolve(bound, undefined, givenPath(msg.workdir)).home; + if (folderHome !== parent.workstream.id) { + return { + type: "response.error", + requestId: msg.id, + error: `A delegated token can't fork a "${parent.workstream.id}" session into a folder whose workstream is ${folderHome ? `"${folderHome}"` : "none"}.`, + code: "forbidden", + }; + } + } + const homing = this.#workstreams.forCreate(bound, parent.workstream.id, givenPath(msg.workdir)); + if (!homing.ok && homing.kind === "workstream_conflict") { + return { + type: "response.error", + requestId: msg.id, + error: `${homing.error} A fork keeps its parent's workstream ("${parent.workstream.id}").`, + code: "invalid_request", + detail: { kind: "workstream_conflict", resolution: homing.resolution }, + }; + } + } forkWorkdir = bound; const branch = await currentBranch(bound); if (branch) worktree = { path: bound, branch, createdByCodeoid: false }; @@ -5485,6 +5664,8 @@ mcpHub: this.#mcpHub, let fork: Session; try { fork = new Session({ + // A fork continues its parent's line of work: same home (G5). + ...this.#workstreamOpts(this.#workstreams.inherit(parent.workstream)), name: msg.name ?? `${parentInfo.name} (fork)`, workdir: forkWorkdir, auth, @@ -5739,6 +5920,7 @@ mcpHub: this.#mcpHub, } const session = new Session({ + ...this.#workstreamOpts(this.#workstreams.conductorBinding()), name: this.#conductorName(), workdir, role: "conductor", @@ -6033,6 +6215,7 @@ mcpHub: this.#mcpHub, const problem = this.#loopWorktreeProblem(opts.workdir); if (problem) throw new Error(problem); const session = new Session({ + ...this.#workstreamOpts(this.#workstreamForFolder(opts.workdir)), name: opts.name, workdir: opts.workdir, providerId: opts.provider, @@ -7060,6 +7243,8 @@ mcpHub: this.#mcpHub, // worktree like any codeoid-made fork; the branch stays, holding only // what the run delivered. const session = new Session({ + // The checkout the loop works on, not its worktree (`-worktrees/…` matches no rule written for the repo). + ...this.#workstreamOpts(this.#workstreamForFolder(run.contract.workdir)), name: `loop-${run.id.slice(5, 13)}`, workdir, ...(run.worktreeRoot && run.branch @@ -7164,6 +7349,8 @@ mcpHub: this.#mcpHub, } } const session = new Session({ + // The routine's own folder, not the worktree it may run in. + ...this.#workstreamOpts(this.#workstreamForFolder(o.workdir)), // The conductor's name is reserved for the singleton. name: o.name === this.#conductorName() ? `${o.name}-routine` : o.name, workdir, @@ -7454,6 +7641,7 @@ mcpHub: this.#mcpHub, } const budget = this.#dispatcher.config.workerToolBudget; const session = new Session({ + ...this.#workstreamOpts(this.#workstreamForFolder(workdir)), name: `worker-${task.shape}-${task.id.slice(0, 8)}`, workdir, role: "worker", diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 58baa4b1..f67114d0 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -101,7 +101,8 @@ import { } from "./checkpoints.js"; import { type PinnedDir, PinnedPathError, pinDir, pinRecord } from "./sandbox/pinned.js"; import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; -import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, TurnSummary } from "../protocol/types.js"; +import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, SessionWorkstreamInfo, TurnSummary } from "../protocol/types.js"; +import type { WorkstreamBinding } from "./workstreams/binding.js"; import { LIMITS } from "../protocol/types.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; import type { PendingToolApproval, SessionApproveOutcome, SessionSandboxInfo, SessionSandboxSetting } from "../protocol/types.js"; @@ -552,6 +553,10 @@ export interface SessionCreateOptions { * orphan an agent subprocess. */ collaborationRole?: SessionInfo["collaborationRole"]; + /** Home workstream (docs/workstreams-design.md), as the manager decided it. Absent = none configured. */ + workstream?: WorkstreamBinding; + /** Renders the binding for `SessionInfo` (name, color, orphaned) from the live config. */ + describeWorkstream?: (binding: WorkstreamBinding | undefined) => SessionWorkstreamInfo | undefined; /** * Role-scoped goal-blackboard mount for a collaboration child: the endpoint * URL plus a bearer token that IS the scope (one goal, this role's read/write @@ -1060,6 +1065,11 @@ export class Session { get worktree(): SessionWorktree | undefined { return this.#worktree; } + /** Home workstream binding (docs/workstreams-design.md G5). */ + get workstream(): WorkstreamBinding | undefined { + return this.#workstream; + } + /** * Goal + role→backend bindings when this session was created with the * Collaborative toggle (set from opts / restored from meta). Readonly: the @@ -1069,6 +1079,9 @@ export class Session { readonly collaboration?: CollaborationConfig; /** Which collaboration + role this session serves, when it is a child. */ readonly collaborationRole?: SessionInfo["collaborationRole"]; + /** Home workstream (docs/workstreams-design.md G5). Sticks: set at create, or once on resume. */ + #workstream?: WorkstreamBinding; + #describeWorkstream?: (binding: WorkstreamBinding | undefined) => SessionWorkstreamInfo | undefined; /** Role-scoped blackboard mount. NOT readonly: the orchestrator's own mount * is scoped to a goal id that IS this session's id, so it can only be * attached after construction (see attachBlackboard). */ @@ -1713,6 +1726,8 @@ export class Session { this.#worktree = opts.worktree; this.collaboration = opts.collaboration; this.collaborationRole = opts.collaborationRole; + this.#workstream = opts.workstream; + this.#describeWorkstream = opts.describeWorkstream; this.#blackboardMcp = opts.blackboardMcp; this.#sandboxMcp = opts.sandboxMcp; this.#sandboxCheckouts = opts.sandboxCheckouts; @@ -1946,7 +1961,11 @@ export class Session { accountId: opts.auth.accountId, projectId: opts.auth.projectId, }); - this.#store.audit(opts.auth.sub, "session.create", this.id, `name=${this.name}${this.#effort ? ` effort=${this.#effort}` : ""}`); + // The home is in the one row every creation path writes (import, loops, + // routines, dispatch, forks, children): it will decide credentials (W1). + const ws = this.#workstream; + const wsDetail = ws ? ` workstream=${ws.id ?? "none"} source=${ws.source}${ws.matchedBy ? ` rule=${JSON.stringify(ws.matchedBy)}` : ""}` : ""; + this.#store.audit(opts.auth.sub, "session.create", this.id, `name=${this.name}${this.#effort ? ` effort=${this.#effort}` : ""}${wsDetail}`); if (this.#effort) this.#store.setSessionEffort(this.id, this.#effort); this.#transcriptStore.saveMeta({ @@ -1968,6 +1987,7 @@ export class Session { worktree: this.worktree, collaboration: this.collaboration, collaborationRole: this.collaborationRole, + ...(this.#workstream ? { workstream: this.#workstream } : {}), // Fire-and-forget: saveMeta's write chain owns the failure log; an // unconsumed rejection here would be an unhandled-rejection crash. }).catch(() => {}); @@ -7760,6 +7780,7 @@ export class Session { worktree: this.worktree, collaboration: this.collaboration, collaborationRole: this.collaborationRole, + ...(this.#workstream ? { workstream: this.#describeWorkstream?.(this.#workstream) ?? { ...(this.#workstream.id ? { id: this.#workstream.id } : {}), source: this.#workstream.source } } : {}), // Ambient pack driving this session (docs/pack-loading.md) — id, or // "id (role)" when a capability role is active. ...(this.#pack @@ -12076,6 +12097,8 @@ export class Session { // transition — i.e. on every session that had taken a single turn. collaboration: this.collaboration, collaborationRole: this.collaborationRole, + // Both meta writes, for the same reason as the collaboration above. + ...(this.#workstream ? { workstream: this.#workstream } : {}), }).catch(() => {}); } } diff --git a/src/daemon/transcript.ts b/src/daemon/transcript.ts index 35e02ed8..a1060726 100644 --- a/src/daemon/transcript.ts +++ b/src/daemon/transcript.ts @@ -14,7 +14,8 @@ */ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, read, readFileSync, readSync, statSync } from "node:fs"; -import { appendFile, rename, rm, writeFile } from "node:fs/promises"; +import type { WorkstreamBinding } from "./workstreams/binding.js"; +import { appendFile, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { CanonicalHistoryChange, CanonicalToolCall, CanonicalTurn } from "./providers/canonical.js"; import type { SessionSandboxSetting, TurnSummary } from "../protocol/types.js"; @@ -233,6 +234,12 @@ export interface TranscriptMeta { * the first status transition. */ collaborationRole?: SessionInfo["collaborationRole"]; + /** + * Home workstream (docs/workstreams-design.md G5). Written in BOTH saveMeta + * calls, like `collaborationRole`. Validated when read + * (`readWorkstreamBinding`): it is read from a file. + */ + workstream?: WorkstreamBinding; } /** Types we persist. Skip ephemeral events like heartbeats. */ @@ -671,13 +678,37 @@ export class TranscriptStore { * keyed by sessionId. */ async saveMeta(meta: TranscriptMeta): Promise { - const id = meta.sessionId; + return this.#enqueueMeta(meta.sessionId, () => this.#writeMetaAtomic(meta)); + } + + /** + * Change only some fields of a session's meta as it is on disk, in the same + * write chain as `saveMeta` (so it lands after any write queued before it). + * For resume (workstreams G5): recording a home decided at this resume must + * not rewrite what the session holds in memory over what the file says. + * A meta that can't be read is left as it is (the error is logged by the chain). + */ + async patchMeta(sessionId: string, patch: Partial>): Promise { + return this.#enqueueMeta(sessionId, async () => { + let text: string; + try { + text = await readFile(this.metaPath(sessionId), "utf-8"); + } catch (err) { + // Gone (the session was destroyed meanwhile): a patch never recreates a meta. + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } + await this.#writeMetaAtomic({ ...(JSON.parse(text) as TranscriptMeta), ...patch, sessionId }); + }); + } + + #enqueueMeta(id: string, write: () => Promise): Promise { const prev = this.#metaWriteChain.get(id) ?? Promise.resolve(); // Absorb the previous write's failure so one rejected write can't skip // every write queued behind it — and LOG failures: *.meta.json is the // sole restart-resume discovery mechanism, so silent failures here mean // sessions vanish on the next restart with zero diagnostic. - const attempt = prev.then(() => this.#writeMetaAtomic(meta)); + const attempt = prev.then(write); // The STORED chain absorbs the rejection (fire-and-forget callers never // consume it — a rejected promise in the map is an unhandled-rejection // crash under Bun) and owns the exactly-once error log. The RETURNED diff --git a/src/daemon/workstreams/binding.ts b/src/daemon/workstreams/binding.ts new file mode 100644 index 00000000..cb55651c --- /dev/null +++ b/src/daemon/workstreams/binding.ts @@ -0,0 +1,41 @@ +/** + * A session's home workstream as persisted in its meta file + * (`TranscriptMeta.workstream`, docs/workstreams-design.md G5). Kept apart from + * the service so the transcript store can carry it without importing matching. + */ + +import type { WorkstreamSource } from "../../protocol/types.js"; + +/** A session's home, as persisted in its meta (TranscriptMeta.workstream). */ +export interface WorkstreamBinding { + /** Absent = no home (`source: "none"`). */ + id?: string; + source: WorkstreamSource; + matchedBy?: string; + /** The config revision the binding was made under. */ + revision: string; +} + +const SOURCES: ReadonlySet = new Set(["explicit", "matched", "default", "override", "migrated", "reserved", "none"]); + +/** + * A binding read back from a meta file, validated: it is read from disk, so + * anything unexpected is dropped (the session is then treated as pre-dating + * workstreams and resolved again on resume — fail toward re-deciding, never + * toward trusting a malformed home). + */ +export function readWorkstreamBinding(raw: unknown): WorkstreamBinding | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const r = raw as Record; + if (typeof r.source !== "string" || !SOURCES.has(r.source)) return undefined; + if (typeof r.revision !== "string" || r.revision.length > 128) return undefined; + if (r.id !== undefined && (typeof r.id !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(r.id))) return undefined; + if (r.matchedBy !== undefined && (typeof r.matchedBy !== "string" || r.matchedBy.length > 1024)) return undefined; + if ((r.source === "none") !== (r.id === undefined)) return undefined; + return { + ...(typeof r.id === "string" ? { id: r.id } : {}), + source: r.source as WorkstreamSource, + ...(typeof r.matchedBy === "string" ? { matchedBy: r.matchedBy } : {}), + revision: r.revision, + }; +} diff --git a/src/daemon/workstreams/effective.ts b/src/daemon/workstreams/effective.ts new file mode 100644 index 00000000..8331192e --- /dev/null +++ b/src/daemon/workstreams/effective.ts @@ -0,0 +1,122 @@ +/** + * A workstream with its bases merged in (docs/workstreams-design.md §6.5, + * case 3). + * + * Merge rules: + * - lists (servers, skill dirs) are unioned in `extends` order, the + * workstream's own entries last; a later entry for the same server + * replaces an earlier one (so a workstream can give an inherited server + * its own credential); + * - maps (env, accounts) and scalars (defaults, git fields) are overridden by + * the most specific setting; + * - `exclude` is applied after the merge; + * - `match`, `claim`, `share` and `allowOverride` are never inherited: they + * describe this workstream's own folders. + * + * The config was validated (no cycles, every base exists) before this runs; + * the visited set is a backstop, never the check. + */ + +import type { WorkstreamItemConfig, WorkstreamServerRef } from "./schema.js"; + +export interface EffectiveServer { + server: string; + /** Credential slot; absent = the server's default sign-in. */ + credential?: string; + /** Which workstream contributed it (itself or a base). */ + from: string; +} + +export interface EffectiveWorkstream { + id: string; + name: string; + color?: string; + abstract: boolean; + extends: string[]; + claim: "exclusive" | "suggest"; + allowOverride: boolean; + share: "open" | "ask" | "closed"; + match: { paths: string[]; remotes: string[] }; + servers: EffectiveServer[]; + accounts: Record; + defaults: { provider?: string; model?: string; effort?: WorkstreamItemConfig["defaults"]["effort"] }; + env: Record; + git?: { name?: string; email?: string }; + skillDirs: string[]; +} + +function refName(ref: WorkstreamServerRef): { server: string; credential?: string } { + if (typeof ref === "string") return { server: ref.startsWith("+") ? ref.slice(1) : ref }; + return { server: ref.server.startsWith("+") ? ref.server.slice(1) : ref.server, ...(ref.credential ? { credential: ref.credential } : {}) }; +} + +export function effectiveWorkstreams(items: readonly WorkstreamItemConfig[]): Map { + const byId = new Map(items.map((i) => [i.id, i] as const)); + const out = new Map(); + + const build = (id: string, visiting: Set): EffectiveWorkstream | undefined => { + const done = out.get(id); + if (done) return done; + const item = byId.get(id); + if (!item || visiting.has(id)) return undefined; + visiting.add(id); + + const servers = new Map(); + const skillDirs: string[] = []; + let accounts: Record = {}; + let defaults: EffectiveWorkstream["defaults"] = {}; + let env: Record = {}; + let git: EffectiveWorkstream["git"]; + + const layer = ( + from: string, + layerServers: Iterable, + dirs: readonly string[], + acc: Record, + def: EffectiveWorkstream["defaults"], + e: Record, + g: EffectiveWorkstream["git"], + ): void => { + for (const s of layerServers) { + servers.delete(s.server); // re-insert so the most specific entry also sits last + servers.set(s.server, { server: s.server, ...(s.credential ? { credential: s.credential } : {}), from: "from" in s ? s.from : from }); + } + for (const d of dirs) if (!skillDirs.includes(d)) skillDirs.push(d); + accounts = { ...accounts, ...acc }; + defaults = { ...defaults, ...Object.fromEntries(Object.entries(def).filter(([, v]) => v !== undefined)) }; + env = { ...env, ...e }; + if (g) git = { ...(git ?? {}), ...Object.fromEntries(Object.entries(g).filter(([, v]) => v !== undefined)) }; + }; + + for (const baseId of item.extends) { + const base = build(baseId, visiting); + if (base) layer(base.id, base.servers, base.skillDirs, base.accounts, base.defaults, base.env, base.git); + } + layer(id, item.mcpServers.map(refName), item.skillDirs, item.accounts, item.defaults, item.env, item.git); + + const excluded = new Set(item.exclude.map((x) => (x.startsWith("+") ? x.slice(1) : x))); + const eff: EffectiveWorkstream = { + id, + name: item.name ?? id, + ...(item.color ? { color: item.color } : {}), + abstract: item.abstract, + extends: [...item.extends], + claim: item.claim, + allowOverride: item.allowOverride, + share: item.share, + match: { paths: [...item.match.paths], remotes: [...item.match.remotes] }, + servers: [...servers.values()].filter((s) => !excluded.has(s.server)), + accounts, + defaults, + env: Object.fromEntries(Object.entries(env).filter(([k]) => !excluded.has(k))), + ...(git ? { git } : {}), + skillDirs: skillDirs.filter((d) => !excluded.has(d)), + }; + visiting.delete(id); + out.set(id, eff); + return eff; + }; + + for (const item of items) build(item.id, new Set()); + return out; +} diff --git a/src/daemon/workstreams/folder.ts b/src/daemon/workstreams/folder.ts new file mode 100644 index 00000000..56753c18 --- /dev/null +++ b/src/daemon/workstreams/folder.ts @@ -0,0 +1,214 @@ +/** + * What the matcher needs to know about a folder (docs/workstreams-design.md + * §5.1): its two spellings (as given, and its real path), and its git remotes. + * + * Only those two spellings, never ones found by scanning for symlinks: a link + * anyone can plant under a rule's folder would otherwise be a spelling an + * attacker chose, re-homing an exclusive folder or pulling a foreign one into + * an exclusive workstream (W0 audit). The given path is the caller's choice + * and the real path is the kernel's; neither is a link someone else made. + * + * Read-only and bounded. The git remotes come from the repository's config + * file, parsed here, never from running `git`: a repository an agent can + * write could otherwise pick what runs (an `include.path`, a config-driven + * helper). Parsing only `[remote "…"] url` means a hostile config can make a + * folder look like it belongs elsewhere, and nothing more — which is why a + * path rule outranks a remote rule (match.ts) and a session's home sticks + * once chosen (G5). + */ + +import { closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, constants as fsc } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { type FolderFacts, normalizeRemote, parseSshAliases } from "./match.js"; + +/** Largest git config or ssh config read. */ +const MAX_CONFIG_BYTES = 1_000_000; +/** Most folders above a workdir searched for `.git`. */ +const MAX_WALK = 64; +/** + * Read a small regular file, never through a final symlink; undefined on + * anything odd. Opened non-blocking: a FIFO planted where a config is read + * would otherwise block `open` until a writer came, freezing the daemon's + * event loop (W0 audit) — non-blocking, the open returns and `fstat` refuses + * it as not a regular file. + */ +function readSmall(path: string): string | undefined { + let fd: number | undefined; + try { + fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW | fsc.O_NONBLOCK); + const st = fstatSync(fd); + if (!st.isFile() || st.size > MAX_CONFIG_BYTES) return undefined; + const buf = Buffer.alloc(st.size); + let off = 0; + while (off < st.size) { + const n = readSync(fd, buf, off, st.size - off, off); + if (n <= 0) break; + off += n; + } + return buf.subarray(0, off).toString("utf8"); + } catch { + return undefined; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +function realOr(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** The git dir and common dir for a folder, found by walking up. */ +export function findGitDirs(start: string): { gitDir: string; commonDir: string } | undefined { + let cur = start; + for (let i = 0; i < MAX_WALK; i += 1) { + const dotGit = join(cur, ".git"); + let st: ReturnType | undefined; + try { + st = lstatSync(dotGit); + } catch { + st = undefined; + } + // A `.git` that is a link is no repository we read — and must not be + // walked past either, or a parent repository (a dotfiles repo in $HOME) + // would lend this folder its remotes. + if (st?.isSymbolicLink()) return undefined; + if (st?.isDirectory()) return { gitDir: dotGit, commonDir: commonOf(dotGit) }; + if (st?.isFile()) { + const text = readSmall(dotGit); + const m = text ? /^gitdir:\s*(.+?)\s*$/m.exec(text) : null; + if (!m) return undefined; + const gitDir = isAbsolute(m[1]) ? m[1] : resolve(cur, m[1]); + return { gitDir, commonDir: commonOf(gitDir) }; + } + const up = dirname(cur); + if (up === cur) return undefined; + cur = up; + } + return undefined; +} + +function commonOf(gitDir: string): string { + const text = readSmall(join(gitDir, "commondir")); + const rel = text?.trim(); + if (!rel) return gitDir; + return isAbsolute(rel) ? rel : resolve(gitDir, rel); +} + +/** + * `[remote "name"] url = …` pairs of a git config. The first `url` of a remote + * wins (git pushes to every url but fetches from the first). Subsections are + * case-sensitive, section names and keys are not, as git reads them. + */ +export function parseGitRemotes(text: string): Map { + const out = new Map(); + let remote: string | undefined; + for (const line of text.split(/\r?\n/)) { + const t = line.trim(); + if (t.length === 0 || t.startsWith("#") || t.startsWith(";")) continue; + const section = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]/.exec(t); + if (section) { + remote = section[1].toLowerCase() === "remote" && section[2] !== undefined ? section[2].replace(/\\(.)/g, "$1") : undefined; + continue; + } + if (remote === undefined) continue; + const kv = /^([A-Za-z][A-Za-z0-9-]*)\s*=\s*(.*)$/.exec(t); + if (!kv || kv[1].toLowerCase() !== "url") continue; + const value = gitConfigValue(kv[2]); + if (!out.has(remote) && value.length > 0) out.set(remote, value); + } + return out; +} + +export interface FolderReaderOptions { + home?: string; + /** ssh config text; default `~/.ssh/config`. */ + sshConfig?: string; +} + +/** + * Gathers FolderFacts. One per daemon; caches ssh aliases (by the file's + * mtime), never a resolution — a session's home is decided once and kept by + * the session, not here. + */ +export class FolderReader { + readonly home: string; + #sshPath: string; + #sshText: string | undefined; + #sshAliases: { mtimeMs: number; map: Map } | undefined; + + constructor(opts: FolderReaderOptions = {}) { + this.home = opts.home ?? homedir(); + this.#sshPath = join(this.home, ".ssh", "config"); + this.#sshText = opts.sshConfig; + } + + /** `Host` → `HostName` aliases from the ssh config. */ + sshAliases(): Map { + if (this.#sshText !== undefined) { + if (!this.#sshAliases) this.#sshAliases = { mtimeMs: 0, map: parseSshAliases(this.#sshText) }; + return this.#sshAliases.map; + } + let mtimeMs = -1; + try { + mtimeMs = statSync(this.#sshPath).mtimeMs; + } catch { + return new Map(); + } + if (this.#sshAliases?.mtimeMs === mtimeMs) return this.#sshAliases.map; + const text = readSmall(this.#sshPath); + const map = text ? parseSshAliases(text) : new Map(); + this.#sshAliases = { mtimeMs, map }; + return map; + } + + /** The folder's normalised `origin` and `upstream`. */ + remotes(folder: string): { origin?: string; upstream?: string } { + const dirs = findGitDirs(realOr(folder)); + if (!dirs) return {}; + const text = readSmall(join(dirs.commonDir, "config")); + if (!text) return {}; + const raw = parseGitRemotes(text); + const aliases = this.sshAliases(); + const origin = raw.get("origin"); + const upstream = raw.get("upstream"); + return { + ...(origin ? { origin: normalizeRemote(origin, aliases) } : {}), + ...(upstream ? { upstream: normalizeRemote(upstream, aliases) } : {}), + }; + } + + /** The folder as given and as its real path (deduped). */ + spellings(folder: string): string[] { + return [...new Set([folder, realOr(folder)])]; + } + + facts(folder: string, wantRemotes: boolean): FolderFacts { + return { paths: this.spellings(folder), ...(wantRemotes ? this.remotes(folder) : {}) }; + } +} + +/** + * A git config value as git reads it: `#` or `;` outside quotes ends it, + * quotes are dropped, `\"` `\\` `\n` `\t` unescaped. (A line ending in `\` + * continues git's value; such a url is not one anyone writes, and is left + * cut short here — a non-match, never a wrong one.) + */ +function gitConfigValue(raw: string): string { + let out = ""; + let quoted = false; + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (ch === "\\" && i + 1 < raw.length) { + const nx = raw[++i]; + out += nx === "n" ? "\n" : nx === "t" ? "\t" : nx; + } else if (ch === '"') quoted = !quoted; + else if (!quoted && (ch === "#" || ch === ";")) break; + else out += ch; + } + return out.trim(); +} diff --git a/src/daemon/workstreams/match.ts b/src/daemon/workstreams/match.ts new file mode 100644 index 00000000..cbcfe7a5 --- /dev/null +++ b/src/daemon/workstreams/match.ts @@ -0,0 +1,293 @@ +/** + * Workstream matching — pure (docs/workstreams-design.md §5.1). + * + * Nothing here touches the filesystem: the caller hands in every spelling of + * the folder (as given, its real path, its aliases through a symlink a rule's + * prefix names) and the folder's normalised remotes. `folder.ts` gathers + * those; this module only decides. + * + * Rules: + * - A path rule matches a folder when the folder, or any folder above it, + * matches the glob. `~/x` and `~/x/**` therefore mean the same thing. + * - A remote rule matches the `origin` remote, or `upstream`. A match through + * `upstream` alone is only ever a suggestion: you see where the code came + * from, you are not held to it. + * - Any path match beats any remote match. A remote is text an agent can + * edit in `.git/config`, so it must never outrank a rule on the folder + * itself (W0 audit round 2) — exclusive or not. + * - Within a kind, an exclusive claim outranks a suggestion, whatever their + * specificity: a more specific `suggest` path must not take a folder from + * the workstream holding it exclusively (W0 audit). Carving a folder out + * of an exclusive one takes a more specific exclusive rule. + * - Then the most specific RULE wins (never where it matched: `/w/**` + * matching the folder itself must not beat `/w/acme` matching its parent): + * more literal segments, then more literal characters; `origin` beats + * `upstream`. + * - Two workstreams tied at the top are ambiguous: no home is picked, and the + * tie is reported so an explicit choice can settle it. + */ + +export type WorkstreamClaim = "exclusive" | "suggest"; + +/** What the matcher needs of a workstream. */ +export interface MatchableWorkstream { + id: string; + abstract: boolean; + claim: WorkstreamClaim; + /** Path globs, `~` already expanded. */ + paths: readonly string[]; + /** Remote globs over `host/owner/repo`. */ + remotes: readonly string[]; +} + +/** The folder, as every spelling the caller could find. */ +export interface FolderFacts { + /** Absolute spellings of the folder: as given, real path, aliases. Deduped by the caller or not — either works. */ + paths: readonly string[]; + /** Normalised `host/owner/repo` of the `origin` remote, when there is one. */ + origin?: string; + /** Normalised `host/owner/repo` of the `upstream` remote, when there is one. */ + upstream?: string; +} + +export interface MatchCandidate { + workstream: string; + /** The rule that matched, as written in the config (after `~` expansion for paths). */ + rule: string; + kind: "path" | "remote"; + /** For a remote match: which remote. */ + via?: "origin" | "upstream"; + /** The claim this match carries: an `upstream`-only match is downgraded to `suggest`. */ + claim: WorkstreamClaim; + /** Ordering key, highest wins. Exposed for display and tests. */ + score: readonly number[]; +} + +export interface MatchResult { + /** Every workstream that matched, best first, one entry per workstream (its best rule). */ + candidates: MatchCandidate[]; + /** The winner, when there is exactly one best. */ + best?: MatchCandidate; + /** Workstream ids tied for best, when more than one. */ + ambiguous?: string[]; +} + +/** Characters that make a path segment a glob rather than a literal. */ +const GLOB_CHARS = /[*?[\]{}]/; + +/** Count of literal (non-glob) characters, a crude but stable specificity. */ +export function literalLength(glob: string): number { + let n = 0; + for (const ch of glob) if (!GLOB_CHARS.test(ch)) n += 1; + return n; +} + +/** Segments with no glob character: `/home/u/Workspace/acme-*` → 3. */ +export function literalSegments(glob: string): number { + return glob.split("/").filter((s) => s.length > 0 && !GLOB_CHARS.test(s)).length; +} + +/** `/a/b/c` → [`/a/b/c`, `/a/b`, `/a`, `/`]. */ +export function ancestry(path: string): string[] { + const clean = path.length > 1 ? path.replace(/\/+$/, "") : path; + const out: string[] = [clean]; + let cur = clean; + while (cur !== "/" && cur.length > 0) { + const i = cur.lastIndexOf("/"); + cur = i <= 0 ? "/" : cur.slice(0, i); + out.push(cur); + } + return out; +} + +/** Glob matcher, cached per pattern. Bun's Glob: `*` stays in a segment, `**` crosses. */ +const globCache = new Map(); +export function globMatch(pattern: string, value: string): boolean { + let g = globCache.get(pattern); + if (!g) { + g = new Bun.Glob(pattern); + if (globCache.size > 2048) globCache.clear(); + globCache.set(pattern, g); + } + return g.match(value); +} + +/** Best path match of one workstream over every spelling of the folder. */ +function bestPathMatch(ws: MatchableWorkstream, folder: FolderFacts): MatchCandidate | undefined { + let best: MatchCandidate | undefined; + for (const spelling of folder.paths) { + for (const dir of ancestry(spelling)) { + for (const rule of ws.paths) { + // Rules are normalised (normalizePathRule): `x/**` is `x`, and the + // ancestor walk is what covers everything below it. + if (!globMatch(rule, dir)) continue; + const cand: MatchCandidate = { + workstream: ws.id, + rule, + kind: "path", + claim: ws.claim, + score: [2, ws.claim === "exclusive" ? 1 : 0, literalSegments(rule), literalLength(rule), 1], + }; + if (!best || compareScore(cand.score, best.score) > 0) best = cand; + } + } + } + return best; +} + +function bestRemoteMatch(ws: MatchableWorkstream, folder: FolderFacts): MatchCandidate | undefined { + let best: MatchCandidate | undefined; + const remotes: Array<["origin" | "upstream", string | undefined]> = [ + ["origin", folder.origin], + ["upstream", folder.upstream], + ]; + for (const [via, remote] of remotes) { + if (!remote) continue; + for (const rule of ws.remotes) { + if (!globMatch(rule, remote)) continue; + const cand: MatchCandidate = { + workstream: ws.id, + rule, + kind: "remote", + via, + claim: via === "upstream" ? "suggest" : ws.claim, + score: [1, via === "origin" && ws.claim === "exclusive" ? 1 : 0, literalSegments(rule), literalLength(rule), via === "origin" ? 1 : 0], + }; + if (!best || compareScore(cand.score, best.score) > 0) best = cand; + } + } + return best; +} + +export function compareScore(a: readonly number[], b: readonly number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +/** Match a folder against every workstream. Abstract (base) workstreams never match. */ +export function matchFolder(workstreams: readonly MatchableWorkstream[], folder: FolderFacts): MatchResult { + const candidates: MatchCandidate[] = []; + for (const ws of workstreams) { + if (ws.abstract) continue; + const p = bestPathMatch(ws, folder); + const r = bestRemoteMatch(ws, folder); + const pick = p && r ? (compareScore(p.score, r.score) >= 0 ? p : r) : (p ?? r); + if (pick) candidates.push(pick); + } + candidates.sort((a, b) => compareScore(b.score, a.score) || a.workstream.localeCompare(b.workstream)); + if (candidates.length === 0) return { candidates }; + const top = candidates.filter((c) => compareScore(c.score, candidates[0].score) === 0); + if (top.length > 1) return { candidates, ambiguous: top.map((c) => c.workstream) }; + return { candidates, best: candidates[0] }; +} + +// ── Remotes ───────────────────────────────────────────────────────────────── + +/** + * Normalise a git remote URL to `host/owner/repo` (lowercase host, no `.git`, + * no user, no port). `aliases` maps an ssh `Host` alias to its `HostName`, so + * `git@github-work:acme/x.git` reads as `github.com/acme/x` when + * `~/.ssh/config` says `Host github-work` → `HostName github.com`. + * Returns undefined for a local path or anything unparseable. + */ +export function normalizeRemote(url: string, aliases: ReadonlyMap = new Map()): string | undefined { + const raw = url.trim(); + if (raw.length === 0 || raw.length > 2048) return undefined; + let host: string | undefined; + let path: string | undefined; + const scheme = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i.exec(raw); + if (scheme) { + const proto = scheme[1].toLowerCase(); + if (proto === "file") return undefined; + const rest = scheme[2]; + const slash = rest.indexOf("/"); + if (slash <= 0) return undefined; + const authority = rest.slice(0, slash); + path = rest.slice(slash + 1); + host = authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d+$/, ""); + if (host.startsWith("[")) return undefined; // IPv6 literal: not a rule anyone writes + } else { + // scp-like: [user@]host:path — but not a Windows/local path. + const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(raw); + if (!scp) return undefined; + host = scp[1]; + path = scp[2]; + } + if (!host || !path) return undefined; + host = host.toLowerCase(); + host = aliases.get(host) ?? host; + // Lowercased whole: the forges people write rules for treat owner and repo + // names case-insensitively (a rule `github.com/Acme/*` must match `acme/x`). + const cleanPath = path + .replace(/^\/+/, "") + .replace(/\/+$/, "") + .replace(/\.git$/i, "") + .toLowerCase(); + if (cleanPath.length === 0 || cleanPath.split("/").some((s) => s === ".." || s === "")) return undefined; + return `${host}/${cleanPath}`; +} + +/** + * The plain `Host alias` → `HostName real` pairs of an ssh config. Wildcard + * hosts, `Match` blocks and `Include` are ignored: a rule only needs the + * aliases people make for a second account. + */ +export function parseSshAliases(text: string): Map { + const out = new Map(); + let current: string[] = []; + for (const line of text.split(/\r?\n/)) { + const t = line.replace(/#.*$/, "").trim(); + if (t.length === 0) continue; + const m = /^(\S+)\s*(?:=\s*|\s+)(.+)$/.exec(t); + if (!m) continue; + const key = m[1].toLowerCase(); + const value = m[2].trim(); + if (key === "host") { + current = value.split(/\s+/).filter((h) => !/[*?!]/.test(h)).map((h) => h.toLowerCase()); + } else if (key === "match") { + current = []; + } else if (key === "hostname") { + for (const alias of current) if (!out.has(alias)) out.set(alias, value.toLowerCase()); + } + } + return out; +} + +/** `~` → home, for a path rule. */ +export function expandHome(rule: string, home: string): string { + if (rule === "~") return home; + if (rule.startsWith("~/")) return `${home.replace(/\/+$/, "")}/${rule.slice(2)}`; + return rule; +} + +/** + * A path rule in the one form it is compared and matched in: trailing `/` + * and `/**` dropped (each names the same folder, and the ancestor walk covers + * what is below it), repeated slashes collapsed. `/` stays `/`. + */ +export function normalizePathRule(rule: string): string { + let r = rule.replace(/\/{2,}/g, "/"); + for (;;) { + const next = r.replace(/\/\*\*$/, "").replace(/(.)\/$/, "$1"); + if (next === r) break; + r = next; + } + return r.length === 0 ? "/" : r; +} + +/** + * The literal head of a path glob: the folders before the first segment with + * a glob character. `/home/u/Workspace/acme-*` → `/home/u/Workspace`. + */ +export function literalHead(glob: string): string { + const out: string[] = []; + for (const s of glob.split("/")) { + if (GLOB_CHARS.test(s)) break; + out.push(s); + } + const head = out.join("/"); + return head.length === 0 ? "/" : head; +} diff --git a/src/daemon/workstreams/schema.ts b/src/daemon/workstreams/schema.ts new file mode 100644 index 00000000..847ef19f --- /dev/null +++ b/src/daemon/workstreams/schema.ts @@ -0,0 +1,207 @@ +/** + * The `workstreams` config block (docs/workstreams-design.md §4). + * + * A workstream is a named line of work: a set of folders plus the servers, + * credentials, accounts and models its sessions get. The shape is zod; the + * rules across items (unique ids, `extends` resolving and acyclic, `default` + * real) are `workstreamsProblems`, run as a refinement so a bad block fails the + * config load loudly, like every other malformed setting. Rules that need the + * rest of the config (server and credential names) are checked where both are + * known (W1). + * + * Imports zod and the pure matcher alone: `src/config.ts` imports this, so + * anything heavier here would be pulled into every CLI start. + */ + +import { z } from "zod"; +import { normalizePathRule } from "./match.js"; + +/** Ids a config may not use: they name a home the daemon assigns itself. */ +export const RESERVED_WORKSTREAM_IDS = ["conductor"] as const; + +const ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; + +export const workstreamIdSchema = z + .string() + .regex(ID, "a workstream id is lowercase letters, digits and dashes (1–64), not starting or ending with a dash") + .refine((id) => !(RESERVED_WORKSTREAM_IDS as readonly string[]).includes(id), { + message: `reserved workstream id (${RESERVED_WORKSTREAM_IDS.join(", ")})`, + }); + +/** A path rule: a glob over absolute paths, `~` expanded. Matches the folder + * it names and everything below it. */ +const pathRuleSchema = z + .string() + .min(1) + .max(1024) + .refine((p) => p.startsWith("/") || p === "~" || p.startsWith("~/"), { + message: "a path rule is absolute or starts with ~/", + }) + .refine((p) => !/(^|\/)\.\.(\/|$)/.test(p), { message: "a path rule may not contain .." }); + +/** A remote rule: a glob over a normalised git remote, `host/owner/repo`. */ +const remoteRuleSchema = z + .string() + .min(1) + .max(512) + .refine((r) => !r.includes("://") && !r.includes("@") && !r.startsWith("/"), { + message: "a remote rule is host/owner/repo (a glob), without a scheme or user, e.g. github.com/acme/*", + }); + +/** A server a workstream selects: by name, or by name with a credential slot + * (§6.5 case 2, applied in W1). `+name` in an extending workstream reads the + * same as `name`; the prefix is accepted so a list reads as an addition. */ +const serverRefSchema = z.union([ + z.string().min(1).max(128), + z + .object({ + server: z.string().min(1).max(128), + credential: z.string().min(1).max(64).optional(), + }) + .strict(), +]); + +/** An env value is always a reference to a named credential, never inline (§4). */ +const envRefSchema = z.object({ secret: z.string().min(1).max(64) }).strict(); + +const envNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/, "an env var name"); + +export const workstreamItemSchema = z + .object({ + id: workstreamIdSchema, + name: z.string().min(1).max(128).optional(), + color: z.string().min(1).max(32).optional(), + /** A base: no folders, no sessions of its own, only something to extend (§6.5). */ + abstract: z.boolean().default(false), + extends: z.array(workstreamIdSchema).max(16).default([]), + match: z + .object({ + paths: z.array(pathRuleSchema).max(256).default([]), + remotes: z.array(remoteRuleSchema).max(256).default([]), + }) + .strict() + .default({ paths: [], remotes: [] }), + /** `exclusive`: the folders run only with this workstream's identity unless + * overridden (§5.2). `suggest`: the folder only pre-selects it. */ + claim: z.enum(["exclusive", "suggest"]).default("suggest"), + /** `false` makes an override of an exclusive claim unavailable (§5.2). */ + allowOverride: z.boolean().default(true), + /** Whether other workstreams may reference this one (§7.1). */ + share: z.enum(["open", "ask", "closed"]).default("ask"), + mcpServers: z.array(serverRefSchema).max(256).default([]), + /** Names (from the lists above, after `extends`) taken away again. */ + exclude: z.array(z.string().min(1).max(128)).max(256).default([]), + /** Default account per backend id. */ + accounts: z.record(z.string().min(1).max(64), z.string().min(1).max(64)).default({}), + defaults: z + .object({ + provider: z.string().min(1).max(64).optional(), + model: z.string().min(1).max(256).optional(), + effort: z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(), + }) + .strict() + .default({}), + env: z.record(envNameSchema, envRefSchema).default({}), + git: z + .object({ + name: z.string().min(1).max(256).optional(), + email: z.string().min(3).max(256).optional(), + }) + .strict() + .optional(), + skillDirs: z.array(pathRuleSchema).max(64).default([]), + }) + .strict() + .refine((w) => !w.abstract || (w.match.paths.length === 0 && w.match.remotes.length === 0), { + message: "an abstract (base) workstream has no match rules", + path: ["match"], + }); + +export const workstreamsSchema = z + .object({ + /** + * Off (default): every session keeps today's servers, whatever its + * workstream. On: a session gets only what its workstream selects + * (§6.1). Enforcement arrives in W1; until then this is recorded only. + */ + isolateGlobalServers: z.boolean().default(false), + /** Home for a session nothing matches. Absent = such a session has none (§5.1). */ + default: workstreamIdSchema.optional(), + items: z.array(workstreamItemSchema).max(128).default([]), + }) + .strict() + .superRefine((cfg, ctx) => { + for (const problem of workstreamsProblems(cfg)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem.message, path: problem.path }); + } + }) + .default({ isolateGlobalServers: false, items: [] }); + +/** + * Rules across items, which zod's per-item shape can't see: ids unique, + * `extends` naming existing workstreams without a cycle, `default` naming a + * real (non-base) workstream, and no rule written identically in two + * workstreams (a guaranteed tie). Ties between DIFFERENT rules that happen to + * match the same folder can only be found against real folders; those are + * reported at resolution, as ambiguous. + */ +export function workstreamsProblems(cfg: { + default?: string; + items: ReadonlyArray<{ id: string; abstract: boolean; extends: readonly string[]; match: { paths: readonly string[]; remotes: readonly string[] } }>; +}): Array<{ message: string; path: (string | number)[] }> { + const problems: Array<{ message: string; path: (string | number)[] }> = []; + const byId = new Map(); + cfg.items.forEach((item, i) => { + if (byId.has(item.id)) problems.push({ message: `duplicate workstream id "${item.id}"`, path: ["items", i, "id"] }); + else byId.set(item.id, i); + }); + cfg.items.forEach((item, i) => { + item.extends.forEach((base, j) => { + if (base === item.id) problems.push({ message: `workstream "${item.id}" extends itself`, path: ["items", i, "extends", j] }); + else if (!byId.has(base)) problems.push({ message: `workstream "${item.id}" extends unknown workstream "${base}"`, path: ["items", i, "extends", j] }); + }); + }); + // Cycle: depth-first over `extends`, reporting each cycle once at the item that closes it. + const state = new Map(); + const visit = (id: string, trail: string[]): void => { + const s = state.get(id); + if (s === "done") return; + if (s === "open") { + const at = byId.get(id) ?? 0; + problems.push({ message: `workstream extends cycle: ${[...trail.slice(trail.indexOf(id)), id].join(" → ")}`, path: ["items", at, "extends"] }); + return; + } + state.set(id, "open"); + const item = cfg.items[byId.get(id) ?? -1]; + for (const base of item?.extends ?? []) if (byId.has(base) && base !== id) visit(base, [...trail, id]); + state.set(id, "done"); + }; + for (const item of cfg.items) visit(item.id, []); + if (cfg.default !== undefined) { + const i = byId.get(cfg.default); + if (i === undefined) problems.push({ message: `default workstream "${cfg.default}" is not defined`, path: ["default"] }); + else if (cfg.items[i].abstract) problems.push({ message: `default workstream "${cfg.default}" is a base (abstract) workstream`, path: ["default"] }); + } + const seen = new Map(); + cfg.items.forEach((item, i) => { + for (const [kind, rules] of [ + ["path", item.match.paths], + ["remote", item.match.remotes], + ] as const) { + rules.forEach((rule, j) => { + // Compared as matched: `~/x`, `~/x/` and `~/x/**` are one rule; remotes are case-insensitive. + const norm = kind === "path" ? normalizePathRule(rule) : rule.toLowerCase(); + const key = `${kind}\u0000${norm}`; + const other = seen.get(key); + if (other !== undefined && other !== item.id) { + problems.push({ message: `${kind} rule "${rule}" is in both "${other}" and "${item.id}"`, path: ["items", i, "match", kind === "path" ? "paths" : "remotes", j] }); + } else seen.set(key, item.id); + }); + } + }); + return problems; +} + +export type WorkstreamItemConfig = z.infer; +export type WorkstreamsConfig = z.infer; +export type WorkstreamServerRef = z.infer; diff --git a/src/daemon/workstreams/service.ts b/src/daemon/workstreams/service.ts new file mode 100644 index 00000000..6ce00bee --- /dev/null +++ b/src/daemon/workstreams/service.ts @@ -0,0 +1,363 @@ +/** + * WorkstreamService — the daemon's view of the `workstreams` block + * (docs/workstreams-design.md). + * + * Owns: the effective (bases-merged) workstreams, the config revision, and + * deciding a session's home — at create (§5.1, §5.2) and at resume (G5). + * Never owns a session's binding: that lives in the session's meta and sticks. + * + * W0 records and shows. Nothing here changes what a session is given yet; + * enforcement (servers, credentials, env) is W1. + */ + +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import type { SessionWorkstreamInfo, WorkstreamResolution, WorkstreamSummary } from "../../protocol/types.js"; +import { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; +import { effectiveWorkstreams, type EffectiveWorkstream } from "./effective.js"; +import { FolderReader } from "./folder.js"; +import { expandHome, type MatchableWorkstream, matchFolder, normalizePathRule } from "./match.js"; +import type { WorkstreamsConfig } from "./schema.js"; + +export { readWorkstreamBinding, type WorkstreamBinding } from "./binding.js"; + +/** The reserved home of the conductor (§10). */ +export const CONDUCTOR_WORKSTREAM = "conductor"; + +export type CreateBinding = + | { ok: true; binding: WorkstreamBinding | undefined; resolution?: WorkstreamResolution; warnings: string[] } + | { ok: false; kind: "workstream_conflict"; resolution: WorkstreamResolution; error: string } + | { ok: false; kind: "workstream_unknown"; workstream: string; error: string }; + +/** What a meta on disk says about its session, for the resume pre-pass. */ +export interface ResumeMetaFacts { + sessionId: string; + workdir: string; + role?: string; + /** The meta's `workstream` value, unvalidated; absent = it predates workstreams. */ + stored: unknown; + /** A fork's or collaboration child's parent session id. */ + parentId?: string; +} + +export class WorkstreamService { + #cfg: WorkstreamsConfig; + #effective: Map; + #matchables: MatchableWorkstream[]; + /** Expanded (and real-head) path rule → the rule as written, for `matchedBy`. */ + #written = new Map(); + #revision: string; + #reader: FolderReader; + + constructor(cfg: WorkstreamsConfig | undefined, reader: FolderReader = new FolderReader()) { + this.#reader = reader; + this.#cfg = cfg ?? { isolateGlobalServers: false, items: [] }; + this.#effective = new Map(); + this.#matchables = []; + this.#revision = ""; + this.#load(this.#cfg); + } + + #load(cfg: WorkstreamsConfig): void { + this.#cfg = cfg; + this.#effective = effectiveWorkstreams(cfg.items); + this.#written.clear(); + this.#matchables = [...this.#effective.values()].map((w) => ({ + id: w.id, + abstract: w.abstract, + claim: w.claim, + paths: w.match.paths.flatMap((p) => this.#pathRuleForms(p)), + remotes: w.match.remotes.map((r) => r.toLowerCase()), + })); + this.#revision = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16); + } + + /** + * The forms a path rule matches in: as written (`~` expanded, trailing `/` + * and `/**` dropped — they mean the same folder) and, for a `~` rule, with + * the home's real path when the home is a symlink, so `~/Workspace/x` + * matches a canonical workdir. + * + * Only `~` is resolved, never the rest of a rule's fixed part: a folder a + * rule names that doesn't exist yet (or that an agent can replace) would + * otherwise become a link someone else points, re-homing another + * workstream's folder at the next start (W0 audit round 2). The home is the + * daemon's own; anyone who can replace it has every credential anyway. + */ + #pathRuleForms(written: string): string[] { + const forms = [normalizePathRule(expandHome(written, this.#reader.home))]; + if (written === "~" || written.startsWith("~/")) { + const realHome = this.#realHome(); + if (realHome !== this.#reader.home) forms.push(normalizePathRule(expandHome(written, realHome))); + } + for (const f of forms) this.#written.set(f, written); + return forms; + } + + #realHome(): string { + try { + return realpathSync(this.#reader.home); + } catch { + return this.#reader.home; + } + } + + /** Replace the config (a settings write, W2). Live sessions keep their bindings. */ + update(cfg: WorkstreamsConfig): void { + this.#load(cfg); + } + + /** Any workstream configured at all. With none, the daemon behaves as before workstreams. */ + get configured(): boolean { + return this.#cfg.items.length > 0; + } + + get revision(): string { + return this.#revision; + } + + get isolateGlobalServers(): boolean { + return this.#cfg.isolateGlobalServers; + } + + get defaultId(): string | undefined { + return this.#cfg.default; + } + + get(id: string): EffectiveWorkstream | undefined { + return this.#effective.get(id); + } + + /** Why `id` can't be chosen as a home, or undefined when it can. */ + choiceProblem(id: string): string | undefined { + const ws = this.#effective.get(id); + if (!ws) return `Unknown workstream "${id}" — see \`codeoid workstream ls\``; + if (ws.abstract) return `"${id}" is a base workstream: it has no sessions of its own`; + return undefined; + } + + summaries(): WorkstreamSummary[] { + return [...this.#effective.values()].map((w) => ({ + id: w.id, + name: w.name, + ...(w.color ? { color: w.color } : {}), + abstract: w.abstract, + extends: w.extends, + claim: w.claim, + allowOverride: w.allowOverride, + share: w.share, + match: w.match, + servers: w.servers.map((s) => ({ ...s })), + accounts: { ...w.accounts }, + defaults: { ...w.defaults }, + env: Object.keys(w.env), + ...(w.git ? { git: { ...w.git } } : {}), + skillDirs: [...w.skillDirs], + })); + } + + /** + * What `workdir` (an absolute, existing folder, canonical) resolves to, + * with an optional explicit choice. `given` is the path as the caller wrote + * it (`~` expanded), matched too: a rule written through a symlink matches + * the folder reached through it. + * + * When the spellings disagree — the given path matches one workstream and + * the real path another — the folder gets no home until one is chosen: a + * link someone planted must never decide the home (W0 audit). + */ + resolve(workdir: string, explicit?: string, given?: string): WorkstreamResolution { + const wantRemotes = this.#matchables.some((m) => m.remotes.length > 0); + const facts = this.#reader.facts(workdir, wantRemotes); + const spellings = [...new Set([...(given ? [given] : []), ...facts.paths])]; + const m = matchFolder(this.#matchables, { ...facts, paths: spellings }); + // Each spelling on its own (remotes belong to the folder, so they count once, in `m`). + const perSpelling = new Set( + spellings.map((p) => matchFolder(this.#matchables, { paths: [p] }).best?.workstream).filter((x): x is string => x !== undefined), + ); + const disagree = perSpelling.size > 1 ? [...perSpelling].sort() : undefined; + const ambiguous = disagree ?? m.ambiguous; + const best = disagree ? undefined : m.best; + + const candidates = m.candidates.map((c) => ({ + workstream: c.workstream, + rule: c.kind === "path" ? (this.#written.get(c.rule) ?? c.rule) : c.rule, + kind: c.kind, + ...(c.via ? { via: c.via } : {}), + claim: c.claim, + })); + const matchedBy = (ws: string): string | undefined => { + const c = candidates.find((x) => x.workstream === ws); + return c ? (c.kind === "remote" ? `remote ${c.rule}${c.via === "upstream" ? " (upstream)" : ""}` : c.rule) : undefined; + }; + + if (explicit !== undefined) { + // The folder's claimant: the best match when it is exclusive; else, among + // those tied or disagreeing, an exclusive one other than the choice — + // unless the choice is itself one of the tied exclusives: a tie means + // neither outranks the other, so choosing one settles it. A + // suggestion chosen against an exclusive it ties with waives nothing. + const tiedExclusive = (id: string) => ambiguous?.includes(id) && candidates.some((c) => c.workstream === id && c.claim === "exclusive"); + const claimant = + best?.claim === "exclusive" + ? best.workstream + : ambiguous && !tiedExclusive(explicit) + ? candidates.find((c) => ambiguous.includes(c.workstream) && c.workstream !== explicit && c.claim === "exclusive")?.workstream + : undefined; + const base = { candidates, ...(ambiguous ? { ambiguous } : {}) }; + if (claimant && claimant !== explicit) { + return { + home: explicit, + source: "explicit", + ...base, + conflict: { folder: claimant, chosen: explicit, overridable: this.#effective.get(claimant)?.allowOverride ?? true }, + }; + } + const by = matchedBy(explicit); + return { home: explicit, source: "explicit", ...(by ? { matchedBy: by } : {}), ...base }; + } + if (best) return { home: best.workstream, source: "matched", matchedBy: matchedBy(best.workstream), candidates }; + if (ambiguous) return { source: "none", candidates, ambiguous }; + if (this.#cfg.default) return { home: this.#cfg.default, source: "default", candidates }; + return { source: "none", candidates }; + } + + /** + * The home for a new session. Undefined when no workstreams are configured + * (the session carries no binding at all, exactly as before). + * + * Refuses: an unknown or base workstream; a choice past an exclusive claim + * (W1 adds the confirmed override, G3). A folder two workstreams tie on, + * with no choice, gets no home and a warning: W1 requires the choice once + * isolation is on. + */ + forCreate(workdir: string, explicit?: string, given?: string): CreateBinding { + if (explicit !== undefined) { + const problem = this.choiceProblem(explicit); + if (problem) return { ok: false, kind: "workstream_unknown", workstream: explicit, error: problem }; + } + if (!this.configured) return { ok: true, binding: undefined, warnings: [] }; + const resolution = this.resolve(workdir, explicit, given); + if (resolution.conflict) { + const c = resolution.conflict; + return { + ok: false, + kind: "workstream_conflict", + resolution, + error: `This folder belongs to workstream "${c.folder}" (exclusive). Start the session in "${c.folder}", or choose a folder outside it for "${c.chosen}".`, + }; + } + const warnings: string[] = []; + if (resolution.ambiguous && explicit === undefined) { + warnings.push(`Workstreams ${resolution.ambiguous.map((a) => `"${a}"`).join(" and ")} both match this folder — the session has no workstream. Choose one, or make one rule more specific.`); + } + return { ok: true, binding: this.#bind(resolution), resolution, warnings }; + } + + /** A child or fork keeps its parent's home exactly (G5), revision included. */ + inherit(parent: WorkstreamBinding | undefined): WorkstreamBinding | undefined { + return parent ? { ...parent } : undefined; + } + + /** The conductor's home (§10): reserved, never matched. */ + conductorBinding(): WorkstreamBinding | undefined { + if (!this.configured) return undefined; + return { id: CONDUCTOR_WORKSTREAM, source: "reserved", revision: this.#revision }; + } + + /** + * The home of every session coming back from disk (G5), keyed by session + * id. `persist` = the binding differs from what the meta holds and must be + * written (once the session has resumed). + * + * - Stored and valid: kept exactly; the folder is never matched again. A + * home the config no longer has stays, shown orphaned. + * - Stored but malformed, a base workstream, or a reserved home on a + * session that isn't the conductor: no home. Never re-resolved — a + * tampered or corrupt meta must not turn into "the folder's (or the + * default's) credentials" (W0 audit). + * - Absent (the session predates workstreams): a fork or collaboration + * child takes its parent's home (G5: they inherit), anything else is + * resolved once by its folder; with nothing matching and no default it + * has no home (fail closed). Marked `migrated`. + * - The conductor's is always the reserved home. + * + * With no workstreams configured, a stored binding is kept (it applies + * again if workstreams come back) and nothing is resolved. + */ + forResume(metas: readonly ResumeMetaFacts[]): (sessionId: string) => { binding: WorkstreamBinding | undefined; persist: boolean } { + const out = new Map(); + const byId = new Map(metas.map((m) => [m.sessionId, m] as const)); + const none = (): WorkstreamBinding => ({ source: "none", revision: this.#revision }); + + const decide = (m: ResumeMetaFacts, depth: number): { binding: WorkstreamBinding | undefined; persist: boolean } => { + const done = out.get(m.sessionId); + if (done) return done; + let result: { binding: WorkstreamBinding | undefined; persist: boolean }; + if (!this.configured) { + result = { binding: m.stored === undefined ? undefined : readWorkstreamBinding(m.stored), persist: false }; + } else if (m.role === "conductor") { + const same = readWorkstreamBinding(m.stored); + const ok = same?.source === "reserved" && same.id === CONDUCTOR_WORKSTREAM; + result = { binding: ok ? same : this.conductorBinding(), persist: !ok }; + } else if (m.stored !== undefined) { + const parsed = readWorkstreamBinding(m.stored); + const bad = + !parsed || + parsed.source === "reserved" || + parsed.id === CONDUCTOR_WORKSTREAM || + (parsed.id !== undefined && this.#effective.get(parsed.id)?.abstract === true); + result = bad ? { binding: none(), persist: true } : { binding: parsed, persist: false }; + } else { + const parent = m.parentId && depth < 32 ? byId.get(m.parentId) : undefined; + const inherited = parent ? decide(parent, depth + 1).binding : undefined; + if (inherited) { + result = { binding: inherited.id ? { ...inherited, source: "migrated" } : none(), persist: true }; + } else { + let resolution: WorkstreamResolution; + try { + resolution = this.resolve(expandHome(m.workdir, this.#reader.home)); + } catch { + resolution = { source: "none", candidates: [] }; + } + const bound = this.#bind(resolution); + result = { binding: { ...bound, source: bound.id ? "migrated" : "none" }, persist: true }; + } + } + out.set(m.sessionId, result); + return result; + }; + + // Decided on demand, so only the sessions actually resumed (and their + // ancestors) cost a folder read — none at all with nothing configured. + return (sessionId) => { + const m = byId.get(sessionId); + return m ? decide(m, 0) : { binding: undefined, persist: false }; + }; + } + + #bind(r: WorkstreamResolution): WorkstreamBinding { + if (r.home === undefined) return { source: "none", revision: this.#revision }; + return { id: r.home, source: r.source, ...(r.matchedBy ? { matchedBy: r.matchedBy } : {}), revision: this.#revision }; + } + + /** + * What `SessionInfo.workstream` carries for a binding. Nothing at all when + * no workstreams are configured: removing the block turns workstreams off, + * it doesn't orphan every session. + */ + info(binding: WorkstreamBinding | undefined): SessionWorkstreamInfo | undefined { + if (!binding || !this.configured) return undefined; + if (binding.id === undefined) return { source: binding.source }; + if (binding.id === CONDUCTOR_WORKSTREAM && binding.source === "reserved") { + return { id: CONDUCTOR_WORKSTREAM, name: "Conductor", source: "reserved" }; + } + const ws = this.#effective.get(binding.id); + return { + id: binding.id, + ...(ws ? { name: ws.name, ...(ws.color ? { color: ws.color } : {}) } : { orphaned: true }), + source: binding.source, + ...(binding.matchedBy ? { matchedBy: binding.matchedBy } : {}), + }; + } +} diff --git a/src/daemon/workstreams/workstreams.test.ts b/src/daemon/workstreams/workstreams.test.ts new file mode 100644 index 00000000..e7e8bbfd --- /dev/null +++ b/src/daemon/workstreams/workstreams.test.ts @@ -0,0 +1,513 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readWorkstreamBinding } from "./binding.js"; +import { effectiveWorkstreams } from "./effective.js"; +import { FolderReader, findGitDirs, parseGitRemotes } from "./folder.js"; +import { ancestry, expandHome, matchFolder, normalizePathRule, normalizeRemote, parseSshAliases, type MatchableWorkstream } from "./match.js"; +import { workstreamsSchema } from "./schema.js"; +import { WorkstreamService } from "./service.js"; + +const ws = (id: string, over: Partial = {}): MatchableWorkstream => ({ + id, + abstract: false, + claim: "suggest", + paths: [], + remotes: [], + ...over, +}); + +describe("matchFolder", () => { + test("a path rule matches the folder and everything below it", () => { + const w = [ws("acme", { paths: ["/home/u/Workspace/acme-*"] })]; + expect(matchFolder(w, { paths: ["/home/u/Workspace/acme-api"] }).best?.workstream).toBe("acme"); + expect(matchFolder(w, { paths: ["/home/u/Workspace/acme-api/src/deep"] }).best?.workstream).toBe("acme"); + expect(matchFolder(w, { paths: ["/home/u/Workspace/other"] }).best).toBeUndefined(); + }); + + test("`x/**` also names x itself (rules arrive normalised)", () => { + expect(normalizePathRule("/w/sentisum/**")).toBe("/w/sentisum"); + expect(normalizePathRule("/w/sentisum/")).toBe("/w/sentisum"); + expect(normalizePathRule("/**")).toBe("/"); + const w = [ws("s", { paths: [normalizePathRule("/w/sentisum/**")] })]; + expect(matchFolder(w, { paths: ["/w/sentisum"] }).best?.workstream).toBe("s"); + expect(matchFolder(w, { paths: ["/w/sentisum/a/b"] }).best?.workstream).toBe("s"); + }); + + test("a deeper path match beats a shallower one; any path match beats a remote match", () => { + const w = [ + ws("broad", { paths: ["/w/**"], remotes: ["github.com/acme/*"] }), + ws("narrow", { paths: ["/w/acme-api"] }), + ws("remote", { remotes: ["github.com/acme/api"] }), + ]; + const r = matchFolder(w, { paths: ["/w/acme-api/src"], origin: "github.com/acme/api" }); + expect(r.best?.workstream).toBe("narrow"); + expect(r.candidates.map((c) => c.workstream)).toEqual(["narrow", "broad", "remote"]); + }); + + test("remote rules: origin keeps the claim, upstream alone only suggests", () => { + const w = [ws("hf", { claim: "exclusive", remotes: ["github.com/highflame-ai/*"] })]; + const viaOrigin = matchFolder(w, { paths: ["/x"], origin: "github.com/highflame-ai/zeroid" }); + expect(viaOrigin.best).toMatchObject({ workstream: "hf", via: "origin", claim: "exclusive" }); + const viaUpstream = matchFolder(w, { paths: ["/x"], origin: "github.com/saucam/zeroid", upstream: "github.com/highflame-ai/zeroid" }); + expect(viaUpstream.best).toMatchObject({ workstream: "hf", via: "upstream", claim: "suggest" }); + }); + + test("two workstreams tied at the top are ambiguous, never a guess", () => { + // Same literal segments (w) and characters (/w/p vs /w/t): a true tie. + const w = [ws("a", { paths: ["/w/p*"] }), ws("b", { paths: ["/w/*t"] })]; + const r = matchFolder(w, { paths: ["/w/project"] }); + expect(r.best).toBeUndefined(); + expect(r.ambiguous?.sort()).toEqual(["a", "b"]); + }); + + test("a base workstream never matches", () => { + expect(matchFolder([ws("base", { abstract: true, paths: ["/w/**"] })], { paths: ["/w/x"] }).best).toBeUndefined(); + }); + + test("every spelling counts: a real path matches a rule written through a symlinked parent", () => { + const w = [ws("acme", { paths: ["/home/u/Workspace/acme-*"] })]; + expect(matchFolder(w, { paths: ["/Workspace/acme-api", "/home/u/Workspace/acme-api"] }).best?.workstream).toBe("acme"); + }); + + test("ancestry walks to /", () => { + expect(ancestry("/a/b/c/")).toEqual(["/a/b/c", "/a/b", "/a", "/"]); + expect(ancestry("/")).toEqual(["/"]); + }); +}); + +describe("normalizeRemote", () => { + test.each([ + ["git@github.com:acme/api.git", "github.com/acme/api"], + ["https://github.com/acme/api", "github.com/acme/api"], + ["https://user:tok@GitHub.com/acme/api.git/", "github.com/acme/api"], + ["ssh://git@github.com:22/acme/api.git", "github.com/acme/api"], + ["git@gitlab.com:group/sub/proj.git", "gitlab.com/group/sub/proj"], + ])("%s → %s", (url, want) => { + expect(normalizeRemote(url)).toBe(want); + }); + + test("an ssh Host alias reads as its HostName", () => { + const aliases = parseSshAliases("Host github-work\n HostName github.com\n User git\nHost *\n HostName nope\n"); + expect(normalizeRemote("git@github-work:acme/api.git", aliases)).toBe("github.com/acme/api"); + expect(aliases.has("*")).toBe(false); + }); + + test("local paths and nonsense are not remotes", () => { + expect(normalizeRemote("/srv/git/api.git")).toBeUndefined(); + expect(normalizeRemote("file:///srv/git/api.git")).toBeUndefined(); + expect(normalizeRemote("")).toBeUndefined(); + expect(normalizeRemote("https://github.com/acme/../x")).toBeUndefined(); + }); +}); + +describe("parseGitRemotes", () => { + test("values as git reads them: comments outside quotes end them, quotes and escapes are dropped", () => { + const r = parseGitRemotes('[remote "origin"]\n\turl = "git@github.com:a/b.git"#trailing\n[remote "x"]\n\turl = https://h/a\\"b\n'); + expect(r.get("origin")).toBe("git@github.com:a/b.git"); + expect(r.get("x")).toBe('https://h/a"b'); + }); + + test("reads [remote \"…\"] urls only, first url wins", () => { + const text = [ + "[core]", + " url = not-a-remote", + '[remote "origin"]', + " url = git@github.com:acme/api.git # comment", + " url = https://mirror/acme/api", + '[Remote "upstream"]', + ' URL = "https://github.com/up/api"', + '[branch "main"]', + " remote = origin", + ].join("\n"); + const r = parseGitRemotes(text); + expect(r.get("origin")).toBe("git@github.com:acme/api.git"); + expect(r.get("upstream")).toBe("https://github.com/up/api"); + expect(r.size).toBe(2); + }); +}); + +describe("expandHome", () => { + test("only a leading ~ or ~/", () => { + expect(expandHome("~/x", "/home/u")).toBe("/home/u/x"); + expect(expandHome("~", "/home/u")).toBe("/home/u"); + expect(expandHome("/a/~/b", "/home/u")).toBe("/a/~/b"); + }); +}); + +describe("schema", () => { + const parse = (v: unknown) => workstreamsSchema.safeParse(v); + + test("defaults: nothing configured, isolation off", () => { + expect(parse(undefined).data).toEqual({ isolateGlobalServers: false, items: [] }); + }); + + test("rejects duplicate ids, unknown or cyclic extends, a bad default, the same rule twice", () => { + const issues = (v: unknown) => (parse(v).error?.issues ?? []).map((i) => i.message).join("\n"); + expect(issues({ items: [{ id: "a" }, { id: "a" }] })).toMatch(/duplicate workstream id "a"/); + expect(issues({ items: [{ id: "a", extends: ["nope"] }] })).toMatch(/unknown workstream "nope"/); + expect(issues({ items: [{ id: "a", extends: ["b"] }, { id: "b", extends: ["a"] }] })).toMatch(/cycle/); + expect(issues({ default: "x", items: [{ id: "a" }] })).toMatch(/default workstream "x" is not defined/); + expect(issues({ default: "b", items: [{ id: "b", abstract: true }] })).toMatch(/is a base/); + expect(issues({ items: [{ id: "a", match: { paths: ["~/w"] } }, { id: "b", match: { paths: ["~/w"] } }] })).toMatch(/is in both "a" and "b"/); + }); + + test("rejects reserved ids, relative path rules and remote rules with a scheme", () => { + expect(parse({ items: [{ id: "conductor" }] }).success).toBe(false); + expect(parse({ items: [{ id: "a", match: { paths: ["Workspace/x"] } }] }).success).toBe(false); + expect(parse({ items: [{ id: "a", match: { paths: ["~/w/../etc"] } }] }).success).toBe(false); + expect(parse({ items: [{ id: "a", match: { remotes: ["https://github.com/a/*"] } }] }).success).toBe(false); + }); + + test("a base workstream has no match rules", () => { + expect(parse({ items: [{ id: "b", abstract: true, match: { paths: ["~/w"] } }] }).success).toBe(false); + }); + + test("env values are references, never inline secrets", () => { + expect(parse({ items: [{ id: "a", env: { GH_TOKEN: "ghp_inline" } }] }).success).toBe(false); + expect(parse({ items: [{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }] }).success).toBe(true); + }); +}); + +describe("effectiveWorkstreams", () => { + test("bases merge in order, own entries last, exclude after; match/claim/share never inherited", () => { + const cfg = workstreamsSchema.parse({ + items: [ + { id: "base", abstract: true, mcpServers: ["web", "notes", "github"], skillDirs: ["~/skills"], accounts: { claude: "max" }, share: "closed", env: { A: { secret: "a" } } }, + { + id: "d", + extends: ["base"], + match: { paths: ["~/d/**"] }, + mcpServers: ["+linear", { server: "github", credential: "gh-d" }], + exclude: ["notes"], + accounts: { codex: "work" }, + }, + ], + }); + const d = effectiveWorkstreams(cfg.items).get("d"); + expect(d?.servers).toEqual([ + { server: "web", from: "base" }, + { server: "linear", from: "d" }, + { server: "github", credential: "gh-d", from: "d" }, + ]); + expect(d?.skillDirs).toEqual(["~/skills"]); + expect(d?.accounts).toEqual({ claude: "max", codex: "work" }); + expect(d?.env).toEqual({ A: { secret: "a" } }); + expect(d?.share).toBe("ask"); + expect(d?.match.paths).toEqual(["~/d/**"]); + }); +}); + +describe("readWorkstreamBinding", () => { + test("accepts what the daemon writes", () => { + expect(readWorkstreamBinding({ id: "acme", source: "matched", matchedBy: "~/w/*", revision: "abc" })).toEqual({ + id: "acme", + source: "matched", + matchedBy: "~/w/*", + revision: "abc", + }); + expect(readWorkstreamBinding({ source: "none", revision: "abc" })).toEqual({ source: "none", revision: "abc" }); + }); + + test("drops anything malformed", () => { + for (const bad of [null, [], "x", { source: "nope", revision: "r" }, { id: "Bad Id", source: "matched", revision: "r" }, { source: "none", id: "a", revision: "r" }, { id: "a", source: "matched" }, { source: "matched", revision: "r" }]) { + expect(readWorkstreamBinding(bad)).toBeUndefined(); + } + }); +}); + +// ── Against real folders ──────────────────────────────────────────────────── + +let tmp: string; +beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-"))); +}); +afterEach(() => rmSync(tmp, { recursive: true, force: true })); + +function repo(dir: string, remotes: Record): void { + mkdirSync(join(dir, ".git"), { recursive: true }); + writeFileSync( + join(dir, ".git", "config"), + Object.entries(remotes) + .map(([n, u]) => `[remote "${n}"]\n\turl = ${u}\n`) + .join(""), + ); +} + +describe("FolderReader", () => { + test("reads origin and upstream of a repo, from a subfolder, without running git", () => { + const r = join(tmp, "api"); + repo(r, { origin: "git@github-work:acme/api.git", upstream: "https://github.com/up/api" }); + mkdirSync(join(r, "src")); + const reader = new FolderReader({ home: tmp, sshConfig: "Host github-work\n HostName github.com\n" }); + expect(reader.remotes(join(r, "src"))).toEqual({ origin: "github.com/acme/api", upstream: "github.com/up/api" }); + }); + + test("follows a linked worktree's .git file to the common dir's config", () => { + const main = join(tmp, "main"); + repo(main, { origin: "https://github.com/acme/api" }); + const wtGit = join(main, ".git", "worktrees", "wt1"); + mkdirSync(wtGit, { recursive: true }); + writeFileSync(join(wtGit, "commondir"), "../..\n"); + const wt = join(tmp, "api-worktrees", "wt1"); + mkdirSync(wt, { recursive: true }); + writeFileSync(join(wt, ".git"), `gitdir: ${wtGit}\n`); + expect(findGitDirs(wt)?.commonDir).toBe(join(main, ".git")); + expect(new FolderReader({ home: tmp, sshConfig: "" }).remotes(wt)).toEqual({ origin: "github.com/acme/api" }); + }); + + test("a folder outside any repo has no remotes", () => { + expect(new FolderReader({ home: tmp, sshConfig: "" }).remotes(tmp)).toEqual({}); + }); + + test("spellings are the folder as given and its real path, never links found elsewhere", () => { + const real = join(tmp, "real", "acme-api"); + mkdirSync(real, { recursive: true }); + mkdirSync(join(tmp, "home", "Workspace"), { recursive: true }); + symlinkSync(real, join(tmp, "home", "Workspace", "acme-api")); + const reader = new FolderReader({ home: join(tmp, "home"), sshConfig: "" }); + expect(reader.spellings(join(tmp, "home", "Workspace", "acme-api")).sort()).toEqual([join(tmp, "home", "Workspace", "acme-api"), real].sort()); + expect(reader.spellings(real)).toEqual([real]); + }); + + test("a FIFO where a git config is read never blocks (W0 audit)", async () => { + const { execFileSync } = await import("node:child_process"); + const r = join(tmp, "repo"); + const evil = join(tmp, "evil"); + mkdirSync(r); + mkdirSync(evil); + execFileSync("mkfifo", [join(evil, "commondir")]); + writeFileSync(join(r, ".git"), `gitdir: ${evil}\n`); + const started = Date.now(); + expect(new FolderReader({ home: tmp, sshConfig: "" }).remotes(r)).toEqual({}); + expect(Date.now() - started).toBeLessThan(1000); + }); + + test("a .git that is a symlink stops the walk: no parent repository lends its remotes", () => { + repo(tmp, { origin: "https://github.com/dotfiles/home" }); + const inner = join(tmp, "project"); + mkdirSync(join(tmp, "elsewhere"), { recursive: true }); + mkdirSync(inner); + symlinkSync(join(tmp, "elsewhere"), join(inner, ".git")); + expect(new FolderReader({ home: tmp, sshConfig: "" }).remotes(inner)).toEqual({}); + }); +}); + +describe("WorkstreamService", () => { + const service = (items: unknown[], extra: Record = {}) => + new WorkstreamService(workstreamsSchema.parse({ items, ...extra }), new FolderReader({ home: tmp, sshConfig: "" })); + + test("no workstreams configured: no binding at all, as before", () => { + const s = service([]); + expect(s.configured).toBe(false); + expect(s.forCreate(tmp)).toEqual({ ok: true, binding: undefined, warnings: [] }); + expect(s.forResume([{ sessionId: "s", workdir: tmp, stored: undefined }])("s")).toEqual({ binding: undefined, persist: false }); + }); + + test("matched, default and none", () => { + mkdirSync(join(tmp, "acme")); + const s = service([{ id: "acme", match: { paths: ["~/acme"] } }, { id: "personal" }], { default: "personal" }); + const matched = s.forCreate(join(tmp, "acme")); + expect(matched.ok && matched.binding).toMatchObject({ id: "acme", source: "matched", matchedBy: "~/acme" }); + const def = s.forCreate(tmp); + expect(def.ok && def.binding).toMatchObject({ id: "personal", source: "default" }); + const none = service([{ id: "acme", match: { paths: ["~/acme"] } }]).forCreate(tmp); + expect(none.ok && none.binding).toMatchObject({ source: "none" }); + }); + + test("an explicit choice past an exclusive claim is a conflict; past a suggestion it wins", () => { + mkdirSync(join(tmp, "client")); + mkdirSync(join(tmp, "research")); + const s = service([ + { id: "client", claim: "exclusive", allowOverride: false, match: { paths: ["~/client"] } }, + { id: "research", claim: "suggest", match: { paths: ["~/research"] } }, + { id: "mine" }, + ]); + const c = s.forCreate(join(tmp, "client"), "mine"); + expect(c.ok).toBe(false); + if (!c.ok && c.kind === "workstream_conflict") expect(c.resolution.conflict).toEqual({ folder: "client", chosen: "mine", overridable: false }); + const r = s.forCreate(join(tmp, "research"), "mine"); + expect(r.ok && r.binding).toMatchObject({ id: "mine", source: "explicit" }); + expect(s.forCreate(join(tmp, "client"), "client").ok).toBe(true); + }); + + test("an unknown or base workstream is refused", () => { + const s = service([{ id: "base", abstract: true }]); + const unknown = s.forCreate(tmp, "nope"); + expect(!unknown.ok && unknown.kind).toBe("workstream_unknown"); + const base = s.forCreate(tmp, "base"); + expect(!base.ok && base.kind === "workstream_unknown" && base.error).toMatch(/base workstream/); + }); + + test("a tie with no choice gets no home and a warning; choosing one settles it", () => { + mkdirSync(join(tmp, "x")); + const s = service([ + { id: "a", match: { paths: ["~/*"] } }, + { id: "b", match: { paths: ["~/?"] } }, + ]); + const tie = s.forCreate(join(tmp, "x")); + expect(tie.ok && tie.binding).toMatchObject({ source: "none" }); + expect(tie.ok && tie.warnings[0]).toMatch(/both match this folder/); + expect(s.forCreate(join(tmp, "x"), "b").ok).toBe(true); + }); + + test("resume keeps a stored home; a pre-workstreams session is resolved once, failing closed", () => { + mkdirSync(join(tmp, "acme")); + const s = service([{ id: "acme", match: { paths: ["~/acme"] } }]); + const stored = { id: "other", source: "explicit" as const, revision: "old" }; + const homes = s.forResume([ + { sessionId: "kept", workdir: join(tmp, "acme"), stored }, + { sessionId: "old", workdir: join(tmp, "acme"), stored: undefined }, + { sessionId: "loose", workdir: tmp, stored: undefined }, + { sessionId: "cond", workdir: tmp, role: "conductor", stored: undefined }, + ]); + expect(homes("kept")).toEqual({ binding: stored, persist: false }); + expect(homes("old")).toMatchObject({ binding: { id: "acme", source: "migrated" }, persist: true }); + expect(homes("loose")).toMatchObject({ binding: { source: "none" }, persist: true }); + expect(homes("cond")).toMatchObject({ binding: { id: "conductor", source: "reserved" }, persist: true }); + }); + + test("resume fails closed on a tampered meta: malformed, a base, or a reserved home on a non-conductor (W0 audit)", () => { + mkdirSync(join(tmp, "acme")); + const s = service([{ id: "acme", match: { paths: ["~/acme"] } }, { id: "base", abstract: true }], { default: "acme" }); + const homes = s.forResume([ + { sessionId: "junk", workdir: join(tmp, "acme"), stored: { id: "acme" } }, + { sessionId: "base", workdir: join(tmp, "acme"), stored: { id: "base", source: "explicit", revision: "r" } }, + { sessionId: "fake", workdir: join(tmp, "acme"), stored: { id: "conductor", source: "reserved", revision: "r" } }, + ]); + for (const id of ["junk", "base", "fake"]) expect(homes(id)).toMatchObject({ binding: { source: "none" }, persist: true }); + }); + + test("a pre-workstreams fork or child takes its parent's home, not its own folder's", () => { + mkdirSync(join(tmp, "acme")); + mkdirSync(join(tmp, "elsewhere")); + const s = service([{ id: "acme", match: { paths: ["~/acme"] } }, { id: "other", match: { paths: ["~/elsewhere"] } }]); + const homes = s.forResume([ + { sessionId: "fork", workdir: join(tmp, "elsewhere"), stored: undefined, parentId: "parent" }, + { sessionId: "parent", workdir: join(tmp, "acme"), stored: undefined }, + { sessionId: "grandchild", workdir: join(tmp, "elsewhere"), stored: undefined, parentId: "fork" }, + ]); + expect(homes("fork")?.binding).toMatchObject({ id: "acme", source: "migrated" }); + expect(homes("grandchild")?.binding).toMatchObject({ id: "acme", source: "migrated" }); + }); + + test("with the workstreams block removed, stored homes are kept but not shown, nothing is orphaned", () => { + const s = service([]); + const stored = { id: "acme", source: "matched" as const, revision: "r" }; + expect(s.forResume([{ sessionId: "a", workdir: tmp, stored }])("a")).toEqual({ binding: stored, persist: false }); + expect(s.info(stored)).toBeUndefined(); + }); + + test("an exclusive claim outranks a more specific suggestion (W0 audit)", () => { + mkdirSync(join(tmp, "client", "research"), { recursive: true }); + const s = service([ + { id: "client", claim: "exclusive", match: { paths: ["~/client/**"] } }, + { id: "research", match: { paths: ["~/client/research"] } }, + ]); + expect(s.resolve(join(tmp, "client", "research")).home).toBe("client"); + expect(s.forCreate(join(tmp, "client", "research"), "research").ok).toBe(false); + }); + + test("a folder whose spellings disagree gets no home until chosen; a planted link can't re-home it (W0 audit)", () => { + const acme = join(tmp, "acme", "sub"); + mkdirSync(acme, { recursive: true }); + mkdirSync(join(tmp, "mine")); + symlinkSync(acme, join(tmp, "mine", "planted")); + const s = service([ + { id: "acme", claim: "exclusive", allowOverride: false, match: { paths: ["~/acme/**"] } }, + { id: "mine", match: { paths: ["~/mine/*"] } }, + ]); + // Reached through the link: given says mine, real says acme. + const r = s.resolve(acme, undefined, join(tmp, "mine", "planted")); + expect(r.home).toBeUndefined(); + expect(r.ambiguous).toEqual(["acme", "mine"]); + expect(s.forCreate(acme, "mine", join(tmp, "mine", "planted")).ok).toBe(false); + // Its real path alone is acme's, whatever links exist. + expect(s.resolve(acme).home).toBe("acme"); + }); + + test("a rule written under a symlinked home matches the canonical workdir", () => { + const realHome = join(tmp, "realhome"); + mkdirSync(join(realHome, "Workspace", "acme-api"), { recursive: true }); + symlinkSync(realHome, join(tmp, "linkhome")); + const s = new WorkstreamService( + workstreamsSchema.parse({ items: [{ id: "acme", match: { paths: ["~/Workspace/acme-*"] } }] }), + new FolderReader({ home: join(tmp, "linkhome"), sshConfig: "" }), + ); + expect(s.resolve(join(realHome, "Workspace", "acme-api")).home).toBe("acme"); + expect(s.resolve(join(realHome, "Workspace", "acme-api")).matchedBy).toBe("~/Workspace/acme-*"); + }); + + test("x, x/ and x/** are one rule: they tie, and two of them in different workstreams is a config error", () => { + mkdirSync(join(tmp, "x", "y"), { recursive: true }); + const tie = service([ + { id: "a", match: { paths: ["~/x/**"] } }, + { id: "b", match: { paths: ["~/x"] } }, + ].slice(0, 1).concat([{ id: "b", match: { paths: ["~/z"] } }])); + expect(tie.resolve(join(tmp, "x", "y")).home).toBe("a"); + expect(tie.resolve(join(tmp, "x")).home).toBe("a"); + expect(workstreamsSchema.safeParse({ items: [{ id: "a", match: { paths: ["~/x/**"] } }, { id: "b", match: { paths: ["~/x/"] } }] }).success).toBe(false); + }); + + test("a path rule beats an exclusive remote rule: a forged origin can't take a folder (W0 audit round 2)", () => { + const r = join(tmp, "research"); + repo(r, { origin: "https://github.com/highflame-ai/x" }); + const s = service([ + { id: "research", match: { paths: ["~/research"] } }, + { id: "hf", claim: "exclusive", match: { remotes: ["github.com/highflame-ai/*"] } }, + ]); + expect(s.resolve(r).home).toBe("research"); + expect(s.forCreate(r, "research").ok).toBe(true); + }); + + test("choosing one of two tied exclusive workstreams settles the tie; a suggestion can't (W0 audit round 2)", () => { + mkdirSync(join(tmp, "w", "acme"), { recursive: true }); + const s = service([ + { id: "a", claim: "exclusive", match: { paths: ["~/w/acm*"] } }, + { id: "b", claim: "exclusive", match: { paths: ["~/w/ac*e"] } }, + { id: "c", match: { paths: ["~/never"] } }, + ]); + const dir = join(tmp, "w", "acme"); + expect(s.resolve(dir).ambiguous).toEqual(["a", "b"]); + expect(s.forCreate(dir, "a").ok).toBe(true); + expect(s.forCreate(dir, "b").ok).toBe(true); + expect(s.forCreate(dir, "c").ok).toBe(false); + }); + + test("a rule's own folder is never followed as a link: planting one can't re-home another's folder (W0 audit round 2)", () => { + const secret = join(tmp, "W", "sentisum", "secret"); + mkdirSync(secret, { recursive: true }); + symlinkSync(secret, join(tmp, "W", "acme")); + const s = service([ + { id: "sentisum", claim: "exclusive", match: { paths: ["~/W/sentisum"] } }, + { id: "acme", claim: "exclusive", match: { paths: ["~/W/acme"] } }, + ]); + expect(s.resolve(secret).home).toBe("sentisum"); + }); + + test("remote rules are case-insensitive", () => { + const r = join(tmp, "repo"); + repo(r, { origin: "git@github.com:dhishvari/website.git" }); + expect(service([{ id: "d", match: { remotes: ["github.com/Dhishvari/*"] } }]).resolve(r).home).toBe("d"); + }); + + test("info: name and color from the live config; a deleted home is orphaned", () => { + const s = service([{ id: "acme", name: "Acme Corp", color: "orange" }]); + expect(s.info({ id: "acme", source: "explicit", revision: "r" })).toEqual({ id: "acme", name: "Acme Corp", color: "orange", source: "explicit" }); + expect(s.info({ id: "gone", source: "matched", revision: "r" })).toEqual({ id: "gone", orphaned: true, source: "matched" }); + expect(s.info({ source: "none", revision: "r" })).toEqual({ source: "none" }); + }); + + test("the revision changes with the config and only with it", () => { + const a = service([{ id: "a" }]); + expect(service([{ id: "a" }]).revision).toBe(a.revision); + expect(service([{ id: "b" }]).revision).not.toBe(a.revision); + }); + + test("summaries list env names, never values", () => { + const s = service([{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }]); + expect(s.summaries()[0].env).toEqual(["GH_TOKEN"]); + expect(JSON.stringify(s.summaries())).not.toContain("gh-a"); + }); +}); diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 050597e9..3005cd97 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -365,6 +365,8 @@ export class TerminalClient { if (sandbox) console.log(` ${"".padEnd(20)} ${sandbox}`); const mode = modeSummary(s); if (mode) console.log(` ${"".padEnd(20)} ${mode}`); + const ws = workstreamSummary(s.workstream); + if (ws) console.log(` ${"".padEnd(20)} ${sanitizeTerminalOutput(ws)}`); const effective = s.effectiveAccount ?? s.effectiveClaudeAccount; if (effective) { // Which account it bills on its backend, and whether that is its own choice. @@ -517,6 +519,8 @@ export class TerminalClient { collaboration?: CollaborationConfig; /** OS sandbox (#448); absent = the daemon's config. */ sandbox?: SessionSandboxSetting; + /** Home workstream (docs/workstreams-design.md); absent = the folder's. */ + workstream?: string; } = {}, ): Promise { const resp = await this.#request({ @@ -536,6 +540,7 @@ export class TerminalClient { ...(opts.packRole ? { packRole: opts.packRole } : {}), ...(opts.collaboration ? { collaboration: opts.collaboration } : {}), ...(opts.sandbox ? { sandbox: opts.sandbox } : {}), + ...(opts.workstream ? { workstream: opts.workstream } : {}), }); if (resp.type === "response.ok") { @@ -545,6 +550,8 @@ export class TerminalClient { console.log(`Session created: ${data.name} (${data.id})${provider}${profile}`); const sandbox = sandboxSummary(data.sandbox); if (sandbox) console.log(` ${sandbox}`); + const ws = workstreamSummary(data.workstream); + if (ws) console.log(` ${sanitizeTerminalOutput(ws)}`); // Non-fatal binding notes (skipped cross-vendor bindings, unmapped // tiers) — the daemon adjusted something the operator should know about. for (const w of resp.warnings ?? []) console.log(` ⚠ ${w}`); @@ -561,6 +568,8 @@ export class TerminalClient { } } else { this.#printError(resp); + // A refused create is a failed command: scripts must be able to tell. + process.exitCode = 1; } } @@ -1564,3 +1573,18 @@ export class TerminalClient { } } } + +/** One line saying a session's home workstream and how it got it; undefined when it has none to say. */ +export function workstreamSummary(ws: SessionInfo["workstream"]): string | undefined { + if (!ws) return undefined; + if (!ws.id) return "workstream: none — choose one (`codeoid workstream match ` says why)"; + const label = ws.name && ws.name !== ws.id ? `${ws.name} (${ws.id})` : ws.id; + const how = + ws.source === "matched" ? `matched by ${ws.matchedBy ?? "a rule"}` + : ws.source === "default" ? "the default" + : ws.source === "explicit" ? "chosen" + : ws.source === "override" ? "chosen past the folder's own workstream" + : ws.source === "migrated" ? `from its folder${ws.matchedBy ? ` (${ws.matchedBy})` : ""}` + : ws.source; + return `workstream: ${label} — ${how}${ws.orphaned ? " — no longer configured: move it to another workstream" : ""}`; +} diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index 49f20609..ba89f5c1 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -462,6 +462,10 @@ describe("DaemonMessage routing", () => { return `skill.grant:${msg.allowed}`; case "worktree.trust.result": return `worktree.trust:${msg.trusted}`; + case "workstream.list.result": + return `workstream.list:${msg.workstreams.length}`; + case "workstream.resolve.result": + return `workstream.resolve:${msg.resolution.home ?? "none"}`; case "session.turns.result": return `turns:${msg.turns.length}`; case "session.approvals.result": diff --git a/src/tests/workstreams-manager.test.ts b/src/tests/workstreams-manager.test.ts new file mode 100644 index 00000000..94805374 --- /dev/null +++ b/src/tests/workstreams-manager.test.ts @@ -0,0 +1,265 @@ +/** + * Workstreams through SessionManager.handle (docs/workstreams-design.md, W0): + * a session's home is decided at create, carried on SessionInfo and in its + * meta, refused past an exclusive claim, and — for a session from before + * workstreams — decided once on resume and kept. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type CodeoidConfig, loadConfig } from "../config.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { FolderReader } from "../daemon/workstreams/folder.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, DaemonMessage, SessionInfo } from "../protocol/types.js"; + +const OWNER: AuthContext = { + sub: "user:ws-owner", + scopes: [...ALL_SCOPES] as AuthContext["scopes"], + delegationDepth: 0, + accountId: "acc-ws", + projectId: "proj-ws", +}; +const client = { id: "c-ws", auth: OWNER, send: () => {} }; + +let tmp: string; +let store: Store; +let transcript: TranscriptStore; +const managers: SessionManager[] = []; + +beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-ws-mgr-"))); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + for (const d of ["client", "mine", "loose"]) mkdirSync(join(tmp, "home", d), { recursive: true }); +}); + +afterEach(async () => { + for (const m of managers.splice(0)) { + try { + await m.drain(2_000); + } catch {} + } + try { + await transcript.flush(); + } catch {} + try { + store.close(); + } catch {} + rmSync(tmp, { recursive: true, force: true }); +}); + +function manager(workstreams?: unknown): SessionManager { + // A whole config, through the loader the daemon uses: the zod parse (and its + // cross-item rules) runs exactly as at boot. + const config: CodeoidConfig | undefined = + workstreams === undefined + ? undefined + : loadConfig({ raw: { dbPath: join(tmp, "codeoid.db"), transcriptDir: join(tmp, "transcripts"), workstreams }, env: {}, quiet: true }); + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + ...(config ? { config } : {}), + _testProviderFactory: () => new MockSessionProvider(), + _testFolderReader: new FolderReader({ home: join(tmp, "home"), sshConfig: "" }), + }); + managers.push(m); + return m; +} + +const WS = { + items: [ + { id: "client", name: "Client Co", color: "orange", claim: "exclusive", match: { paths: ["~/client/**"] } }, + { id: "mine", match: { paths: ["~/mine"] } }, + ], +}; + +async function create(m: SessionManager, name: string, dir: string, extra: Record = {}): Promise { + return m.handle({ type: "session.create", id: `c-${name}`, name, workdir: join(tmp, "home", dir), ...extra } as never, OWNER, client); +} + +async function metaOf(id: string): Promise> { + await transcript.flush(); + return JSON.parse(readFileSync(join(tmp, "transcripts", `${id}.meta.json`), "utf8")); +} + +describe("session.create picks the home", () => { + it("by folder, onto SessionInfo and into the meta", async () => { + const resp = await create(manager(WS), "a", "client"); + expect(resp.type).toBe("response.ok"); + const info = (resp as { data: SessionInfo }).data; + expect(info.workstream).toEqual({ id: "client", name: "Client Co", color: "orange", source: "matched", matchedBy: "~/client/**" }); + expect((await metaOf(info.id)).workstream).toMatchObject({ id: "client", source: "matched" }); + }); + + it("refuses an explicit choice past an exclusive claim, saying why", async () => { + const resp = await create(manager(WS), "b", "client", { workstream: "mine" }); + expect(resp).toMatchObject({ type: "response.error", code: "invalid_request", detail: { kind: "workstream_conflict" } }); + if (resp.type === "response.error" && resp.detail?.kind === "workstream_conflict") { + expect(resp.detail.resolution.conflict).toEqual({ folder: "client", chosen: "mine", overridable: true }); + } + }); + + it("refuses an unknown workstream", async () => { + const resp = await create(manager(WS), "c", "mine", { workstream: "nope" }); + expect(resp).toMatchObject({ type: "response.error", detail: { kind: "workstream_unknown", workstream: "nope" } }); + }); + + it("takes an explicit choice in a folder nothing claims", async () => { + const resp = await create(manager(WS), "d", "loose", { workstream: "mine" }); + expect((resp as { data: SessionInfo }).data.workstream).toMatchObject({ id: "mine", source: "explicit" }); + }); + + it("an unmatched folder with no default has no home — and says so", async () => { + const resp = await create(manager(WS), "e", "loose"); + expect((resp as { data: SessionInfo }).data.workstream).toEqual({ source: "none" }); + }); + + it("with no workstreams configured, nothing changes: no field, no refusal", async () => { + const resp = await create(manager(), "f", "client"); + expect(resp.type).toBe("response.ok"); + expect((resp as { data: SessionInfo }).data.workstream).toBeUndefined(); + expect((await metaOf((resp as { data: SessionInfo }).data.id)).workstream).toBeUndefined(); + }); +}); + +describe("who may choose (W0 audit)", () => { + const DELEGATED: AuthContext = { ...OWNER, delegationDepth: 1 }; + + it("a delegated token may confirm the folder's own home, never pick another", async () => { + const m = manager(WS); + const own = await m.handle({ type: "session.create", id: "d1", name: "own", workdir: join(tmp, "home", "mine"), workstream: "mine" } as never, DELEGATED, client); + expect(own.type).toBe("response.ok"); + const other = await m.handle({ type: "session.create", id: "d2", name: "other", workdir: join(tmp, "home", "loose"), workstream: "client" } as never, DELEGATED, client); + expect(other).toMatchObject({ type: "response.error", code: "forbidden" }); + }); + + it("every create with a home is audited with how it was decided", async () => { + const resp = await create(manager(WS), "aud", "client"); + const id = (resp as { data: SessionInfo }).data.id; + const rows = store.database.prepare("SELECT action, detail FROM audit_log WHERE session_id = ? AND action = 'session.create'").all(id) as Array<{ detail: string }>; + expect(rows).toHaveLength(1); + expect(rows[0]!.detail).toContain("workstream=client source=matched"); + }); + + it("an unknown id, even malformed, gets the specific error rather than a schema rejection", async () => { + const resp = await create(manager(WS), "bad", "mine", { workstream: "Not An Id" }); + expect(resp).toMatchObject({ type: "response.error", detail: { kind: "workstream_unknown" } }); + }); +}); + +describe("forks keep their parent's home (W0 audit)", () => { + it("a fork into another workstream's exclusive folder is refused", async () => { + const m = manager({ items: [...WS.items.map((w) => (w.id === "client" ? { ...w, allowOverride: false } : w))] }); + const parent = await create(m, "p", "mine"); + const id = (parent as { data: SessionInfo }).data.id; + const fork = await m.handle({ type: "session.fork", id: "f1", sessionId: id, providerId: "claude", workdir: join(tmp, "home", "client") } as never, OWNER, client); + expect(fork).toMatchObject({ type: "response.error", detail: { kind: "workstream_conflict" } }); + const ok = await m.handle({ type: "session.fork", id: "f2", sessionId: id, providerId: "claude", workdir: join(tmp, "home", "loose") } as never, OWNER, client); + expect(ok.type).toBe("response.ok"); + expect((ok as { data: SessionInfo }).data.workstream).toMatchObject({ id: "mine" }); + }); + + it("a delegated token can't fork a session into a folder of another home", async () => { + const m = manager(WS); + const parent = await create(m, "p2", "mine"); + const id = (parent as { data: SessionInfo }).data.id; + const fork = await m.handle( + { type: "session.fork", id: "f3", sessionId: id, providerId: "claude", workdir: join(tmp, "home", "loose") } as never, + { ...OWNER, delegationDepth: 1 }, + client, + ); + expect(fork).toMatchObject({ type: "response.error", code: "forbidden" }); + }); +}); + +describe("workstream.list and workstream.resolve", () => { + it("list needs settings:read and shows env names only", async () => { + const m = manager({ items: [{ id: "a", env: { GH_TOKEN: { secret: "gh-a" } } }] }); + const denied = await m.handle({ type: "workstream.list", id: "l1" }, { ...OWNER, scopes: [SCOPES.SESSION_CREATE] as AuthContext["scopes"] }, client); + expect(denied).toMatchObject({ type: "response.error", code: "forbidden" }); + const ok = await m.handle({ type: "workstream.list", id: "l2" }, OWNER, client); + expect(ok).toMatchObject({ type: "workstream.list.result", isolateGlobalServers: false, workstreams: [{ id: "a", env: ["GH_TOKEN"] }] }); + }); + + it("resolve answers what a create would, and needs session:create", async () => { + const m = manager(WS); + const denied = await m.handle( + { type: "workstream.resolve", id: "r1", workdir: join(tmp, "home", "client") }, + { ...OWNER, scopes: [SCOPES.SETTINGS_READ] as AuthContext["scopes"] }, + client, + ); + expect(denied).toMatchObject({ type: "response.error", code: "forbidden" }); + const r = await m.handle({ type: "workstream.resolve", id: "r2", workdir: join(tmp, "home", "client"), workstream: "mine" }, OWNER, client); + expect(r).toMatchObject({ + type: "workstream.resolve.result", + resolution: { home: "mine", source: "explicit", conflict: { folder: "client", chosen: "mine" } }, + }); + const missing = await m.handle({ type: "workstream.resolve", id: "r3", workdir: join(tmp, "nowhere") }, OWNER, client); + expect(missing).toMatchObject({ type: "response.error", code: "invalid_request" }); + }); +}); + +describe("resume (G5)", () => { + it("a session from before workstreams is homed once by its folder, and the home is kept", async () => { + // Created before any workstream was configured. + const before = await create(manager(), "old", "client"); + const id = (before as { data: SessionInfo }).data.id; + await transcript.flush(); + + const m2 = manager(WS); + await m2.resumeSessions(); + const info = await listOne(m2, id); + expect(info?.workstream).toMatchObject({ id: "client", source: "migrated" }); + expect((await metaOf(id)).workstream).toMatchObject({ id: "client", source: "migrated" }); + + // The rules change; the session's home doesn't. + const m3 = manager({ items: [{ id: "client", match: { paths: ["~/elsewhere"] } }, { id: "mine", match: { paths: ["~/client"] } }] }); + await m3.resumeSessions(); + expect((await listOne(m3, id))?.workstream).toMatchObject({ id: "client", source: "migrated" }); + }); + + it("migrating a session's home changes only that field of its meta (W0 audit)", async () => { + const before = await create(manager(), "keep", "client"); + const id = (before as { data: SessionInfo }).data.id; + await transcript.flush(); + const path = join(tmp, "transcripts", `${id}.meta.json`); + const meta = JSON.parse(readFileSync(path, "utf8")); + writeFileSync(path, JSON.stringify({ ...meta, redactedBackings: ["backing-1"], providerId: "a-backend-now-gone" })); + + const m2 = manager(WS); + await m2.resumeSessions(); + const after = await metaOf(id); + expect(after.workstream).toMatchObject({ id: "client", source: "migrated" }); + expect(after.redactedBackings).toEqual(["backing-1"]); + expect(after.providerId).toBe("a-backend-now-gone"); + }); + + it("a tampered home fails closed: no home, never the folder's", async () => { + const created = await create(manager(WS), "tamper", "client"); + const id = (created as { data: SessionInfo }).data.id; + await transcript.flush(); + const path = join(tmp, "transcripts", `${id}.meta.json`); + writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, "utf8")), workstream: { id: "client" } })); + const m2 = manager(WS); + await m2.resumeSessions(); + expect((await listOne(m2, id))?.workstream).toEqual({ source: "none" }); + }); + + it("a home removed from the config leaves the session orphaned, not re-homed", async () => { + const created = await create(manager(WS), "g", "mine"); + const id = (created as { data: SessionInfo }).data.id; + await transcript.flush(); + const m2 = manager({ items: [{ id: "client", match: { paths: ["~/client/**"] } }] }); + await m2.resumeSessions(); + expect((await listOne(m2, id))?.workstream).toEqual({ id: "mine", orphaned: true, source: "matched", matchedBy: "~/mine" }); + }); +}); + +async function listOne(m: SessionManager, id: string): Promise { + const r = await m.handle({ type: "session.list", id: "ls" }, OWNER, client); + return r.type === "session.list.result" ? r.sessions.find((s) => s.id === id) : undefined; +}