diff --git a/README.md b/README.md index 48e3e2172..6b39f44fa 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/ed - Git worktree boundary changes (`add` / `remove` / `prune`; read-only `git worktree list` is fine) - Shell that references sensitive paths (`.env`, private keys, certs, credential files, …) - Opaque shell wrappers the policy cannot statically inspect (variable expansion or command substitution in a wrapper payload) -- Paths outside the workspace, writes under `.agent-state`, mutating MCP tools, and unknown built-ins +- Paths outside the workspace, writes under the session state root, mutating MCP tools, and unknown built-ins + ### What auto hard-denies (use the file tools instead) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 09eb70211..dff6f2ef0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,8 @@ The director returns actions that shape the loop: - `capabilities.continue()` — run another inference turn (implicit default). - `capabilities.reply(text)` — inject a synthetic tool result into the next turn's context. -- `capabilities.checkpoint(label)` — persist a named checkpoint to `.agent-state/`. +- `capabilities.checkpoint(label)` — persist a named checkpoint under the session state root (`~/.corbits/projects/...`). + - `capabilities.done()` — terminate the loop. ### Director-layer termination @@ -148,7 +149,8 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l - `types.ts` — `Workflow`, `WorkflowStep` (`prompt`, `capability`, `agent`, `skill`, `workflow` sub-workflow ref, `optional`, `parallel`, `type: "gate"`), and the `WorkflowState` persistence shape. `MAX_WORKFLOW_DEPTH` bounds nesting. - `capabilities.ts` — `detectCapabilities` maps the live tool surface to abstract capabilities (`ticket-tracker`, `code-host`, `doc-search`) by name pattern; `resolveStep` decides whether a step runs. A capability override set forces integrations off per run. Adding a capability is a data edit, not a logic change. -- `runtime.ts` — `WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `.agent-state/workflow.json` for resume. +- `runtime.ts` — `WorkflowRuntime` drives execution on a call stack: it skips capability-unsatisfied steps, descends into sub-workflow references, emits step lifecycle events, and snapshots `WorkflowState`. `state.ts` persists that snapshot atomically to `workflow.json` under the session state root for resume. + - `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and advances the runtime when `advance_workflow` (or a `submit_output` tagged `{ step }`) completes. Shared by both directors. - The built-in recipes: the atomics `update-ticket`, `improve-docs`, `write-tests`, `triage-bug`, `code-review`, `scope-project`, and the `build-feature` composite that chains them. @@ -196,8 +198,8 @@ The agent's identity is **Corbits Code**, framed as a senior coding assistant ru ### State Persistence (`src/session/state.ts`) - `RunState` — `running` | `done` | `failed`, turns used, task, timestamps, error -- Atomic JSON save/load to `.agent-state/run.json`, with schema validation on load -- Conversation context is persisted separately by the git-backed store under `.agent-state/context` +- Atomic JSON save/load to `run.json` under the session state root (`~/.corbits/projects///`), with schema validation on load +- Conversation context is persisted separately by the git-backed store under that session's `context/` directory ### Lifecycle Hooks (`src/session/hooks.ts`) @@ -249,7 +251,8 @@ tool call - **classify** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) are tier `allow`; everything else is tier `ask`. Builds approval requests: shell yields one request for the full command the model asked to run (security still splits under the gate); file tools keyed on the target path; other tools keyed on tool name. - **command** — Splits chained commands for security classification and derives command-shape approval scopes. Multi-segment chains only offer an exact-command persist pattern (a prefix like `npm *` must not cover `npm i && rm -rf /` later). - **auto-shell-policy** — Constrains `run_shell` even when auto mode would otherwise rubber-stamp it. Before matching, `expandShellSubjects` peels `bash`/`sh`/`zsh -c`, `xargs` utility tails, and transparent prefixes (`env`, `nice`, `timeout`, …) so rules see the real payload; an unparseable wrapper (variable expansion or command substitution) sets an opaque flag that forces `ask`. Effects: `deny` blocks outright (file mutations through ad-hoc tooling — output redirection, `tee`, `sed -i`/`perl -i`, interpreter inline programs or heredocs — which must instead go through `write_file`/`edit_file`); `ask` declines to auto-allow and falls through to the operator prompt (recursive `rm`, dependency installs and remote runners: npm/yarn/pnpm/bun, pip, cargo, go, brew, npx/bunx, …, git worktree add/remove/prune, shell that references a sensitive path such as `.env` or a private key, and opaque wrappers). Deny beats ask when multiple subjects match. Quoted spans are stripped before pattern matching so a quoted `>` or install word in an argument is not flagged, and program names are matched only in command position. Adding a table category is a one-line rule append in `AUTO_SHELL_RULES`. -- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under `.agent-state` still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted. +- **gate** — Evaluates a call: `skipPermissions` allows everything; `allow`-tier passes; for `ask`-tier, checks persisted approvals, otherwise requests operator approval. Shell security classifies each chain segment (`||` / `&&` / `|` / `;` / newlines), but the operator is prompted once for the full command block — any unapproved segment fails the whole block, and execution always runs the unsplit original. Safe pipeline tails and pure shell no-ops (`true` / `false` / `:` and bare control-flow keywords stranded by chain-splitting) skip without a prompt. In a non-interactive run an unresolved `ask` becomes a denial. In auto mode: non-shell built-ins in `AUTO_ALLOWED_TOOLS` (writes/edits/deletes, `manage_tasks`, `task`, …) auto-allow when not path-restricted; for `run_shell` the gate consults the auto-shell policy — a `deny` rule fails the call, an `ask` rule skips the auto-allow shortcut and proceeds to the normal approval flow, and anything unmatched is auto-allowed. Paths outside the workspace and writes under the session state root (`~/.corbits/projects/...` and legacy `.agent-state`) still ask. Mutating MCP and unknown built-ins are not blanket-allowed. Newly granted scopes are appended in memory and persisted. + - **matcher** — Approval pattern matching via `@intx/authz` `matchPattern` (`*` wildcards). Exact-command grants store a backslash before each metacharacter; those patterns match by equality after unescape (the package has no escape syntax). - **authz-grants** — Maps stored approvals into `@intx/authz` `GrantRule`s and evaluates them with `evaluateGrants` (allow-only; Corbits cwd/provider-model filters applied first). Exact-escaped grants bypass the package path and use equality. - **store** — Loads/persists approvals scoped to the working directory (Corbits JSON layout; not the package GrantStore). @@ -367,7 +370,8 @@ CLI argv ↓ gates / errors [blocked] → operator resolves → [running] ↓ fatal inference/reactor error - [failed] (TUI may surface and allow retry; context persists under .agent-state/) + [failed] (TUI may surface and allow retry; context persists under the session state root) + ``` There is no post-submit `build`/`typecheck`/`test` critique step in the current tree; validation is operator- and hook-driven (`postTurn`/`postRun`) plus explicit `run_shell` during agent work. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 7363fb182..7acd48fe1 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -155,7 +155,8 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL | **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) | | **ask** | Dependency installs / remote runners, recursive `rm`, git worktree add/remove/prune, sensitive-path references, paths outside the workspace (including through a symlink), opaque unparseable wrappers | -Unmatched shell auto-allows. Writes under `.agent-state`, mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. +Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/projects//…`, and legacy in-repo `.agent-state` during dual-read), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. + Plan approval is handled separately by `use-gates` (`pendingPlan`), independent of auto mode. @@ -292,7 +293,8 @@ Positional arguments are joined into the optional initial task delivered when th ### Agent Source -`createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `.agent-state/context`. +`createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `~/.corbits/projects///context`. + ## Protocols and Formats @@ -303,8 +305,13 @@ Positional arguments are joined into the optional initial task delivered when th ### State Persistence -- `.agent-state/run.json` — `RunState` -- `.agent-state/context/` — git-backed conversation context (`@intx/storage-isogit`) +Session runtime state lives under the global projects tree (not in the repo): + +- `~/.corbits/projects///run.json` — `RunState` +- `~/.corbits/projects///context/` — git-backed conversation context (`@intx/storage-isogit`) +- Project key: slug + short hash of the shared git root (from `--git-common-dir`, so main + linked worktrees share one key; workspace realpath when not a git tree) + +- Migration: if a session exists only under in-repo `.agent-state//`, it is moved into the global tree on open/list - Atomic JSON writes with schema validation on load `createOptimizedContextStore` (`src/session/optimized-context-store.ts`) wraps the diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 4912f91ba..308540374 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -65,7 +65,8 @@ Continues from the last saved state in the working directory. - **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs. - **Still asks**: dependency installs and remote runners (npm/yarn/pnpm/bun, pip, cargo, go, brew, `npx`/`bunx`, …), recursive `rm`, git worktree add/remove/prune (list is fine), shell that references sensitive paths, and opaque unparseable wrappers (variable expansion or command substitution). - **Wrapper peel**: `bash`/`sh`/`zsh -c`, `xargs`, and transparent prefixes (`env`, `nice`, `timeout`, …) are expanded so the same deny/ask rules see the inner payload. - - Paths outside the workspace and writes under `.agent-state` still ask; mutating MCP and unknown tools still prompt. + - Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt. + - **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked. - **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed. @@ -90,7 +91,8 @@ Config-driven `postTurn` and `postRun` hooks (TypeScript or shell) run automatic **What the user sees:** The agent stops producing tool calls. After 3 idle turns the run aborts with `Agent stalled: no tool calls for 3 turns.` -**Recovery:** State is saved; inspect `.agent-state/run.json`, adjust the task or prompt, and start a new run. +**Recovery:** State is saved; inspect `~/.corbits/projects///run.json` (or a legacy in-repo `.agent-state/` tree if not yet migrated), adjust the task or prompt, and start a new run. + ### Permission denied (exec) diff --git a/docs/perftrace-attribution-guide.md b/docs/perftrace-attribution-guide.md index f42bade0b..8f822395b 100644 --- a/docs/perftrace-attribution-guide.md +++ b/docs/perftrace-attribution-guide.md @@ -47,10 +47,10 @@ import { snapshot } from "../src/perf/index.js"; import { dumpSpans } from "../src/perf/dump.js"; const path = await dumpSpans(snapshot(), { - dir: ".agent-state/", + dir: "~/.corbits/projects//", sessionId: "", }); -// → .agent-state//perftrace-.json +// → ~/.corbits/projects///perftrace-.json ``` The dump is privacy-strict (allowlisted tags only). Safe to keep offline or @@ -61,13 +61,13 @@ share with teammates without prompts/paths. From a local dump file alone: ```bash -bun scripts/perf-report.ts .agent-state//perftrace-.json +bun scripts/perf-report.ts ~/.corbits/projects///perftrace-.json ``` Machine-readable JSON: ```bash -bun scripts/perf-report.ts --json .agent-state//perftrace-.json +bun scripts/perf-report.ts --json ~/.corbits/projects///perftrace-.json ``` Golden multi-tool demo (no dump file needed — uses diff --git a/src/permission/classify.ts b/src/permission/classify.ts index 55416c665..a0ab7ed9b 100644 --- a/src/permission/classify.ts +++ b/src/permission/classify.ts @@ -20,7 +20,8 @@ import { runShellAuthzBlockReason, runShellAuthzSegmentBlockReason } from "../sh const READ_ONLY_TOOLS = new Set(["read_file", "search_files", "grep", "list_dir", "lsp"]); // Tools that take a single path-like argument the gate should check against -// restriction (outside the workspace boundary, or writes under .agent-state). +// restriction (outside the workspace boundary, or writes under the session state root). + // Covers both read-only tools (dropped from allow to ask) and the mutating // file tools (dropped from auto-allow to ask in auto mode). const PATH_ARG_TOOLS = new Set(["read_file", "search_files", "grep", "list_dir", "lsp", "write_file", "edit_file", "delete_file"]); @@ -32,7 +33,8 @@ function pathArgKey(toolName: string): string { // write_file/edit_file/delete_file mutate the target; every other path-arg tool only // reads it. Restriction policy (see path-restriction.ts) treats reads and -// writes of an .agent-state path differently, so callers need to tell the +// writes of a session-state path differently, so callers need to tell the + // gate which mode a given tool call is in. function isWriteTool(toolName: string): boolean { return toolName === "write_file" || toolName === "edit_file" || toolName === "delete_file"; diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 3ea670f05..0b3929f34 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -323,7 +323,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd; const isRestrictedHere = bindRestrictedToProcessCwd(isRestricted, effectiveCwd); // A call targeting a restricted path (outside the workspace, or a write - // under .agent-state) drops from allow to ask, so it never auto-allows on + // under the session state root) drops from allow to ask, so it never auto-allows on + // tier or shell-safety below. const restricted = callTargetsRestricted(call, isRestrictedHere); const shellCmd = diff --git a/src/permission/path-restriction.test.ts b/src/permission/path-restriction.test.ts new file mode 100644 index 000000000..6bd0f075f --- /dev/null +++ b/src/permission/path-restriction.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { createPathRestriction } from "./path-restriction.js"; +import { projectSessionsRoot } from "../session/project-key.js"; + +let cwd = ""; +let home = ""; + +beforeEach(async () => { + const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + cwd = join(tmpdir(), `corbits-path-rest-${stamp}`); + home = join(tmpdir(), `corbits-path-rest-home-${stamp}`); + await mkdir(cwd, { recursive: true }); + await mkdir(home, { recursive: true }); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); +}); + +test("legacy .agent-state: reads allow, writes restricted", () => { + const r = createPathRestriction(cwd, () => [], home); + expect(r.isRestricted(".agent-state/run.json", false)).toBe(false); + expect(r.isRestricted(".agent-state/run.json", true)).toBe(true); +}); + +test("global projects session root: reads allow, writes restricted", () => { + const r = createPathRestriction(cwd, () => [], home); + const globalRun = join(projectSessionsRoot(cwd, home), "sess-1", "run.json"); + expect(r.isRestricted(globalRun, false)).toBe(false); + expect(r.isRestricted(globalRun, true)).toBe(true); +}); + +test("other paths under home remain outside-workspace restricted", () => { + const r = createPathRestriction(cwd, () => [], home); + const other = join(home, ".corbits", "settings.json"); + expect(r.isRestricted(other, false)).toBe(true); + expect(r.isRestricted(other, true)).toBe(true); +}); + +test("workspace-relative paths are unrestricted", () => { + const r = createPathRestriction(cwd, () => [], home); + expect(r.isRestricted("src/index.ts", false)).toBe(false); + expect(r.isRestricted("src/index.ts", true)).toBe(false); +}); diff --git a/src/permission/path-restriction.ts b/src/permission/path-restriction.ts index d39c25a93..73cf07ebd 100644 --- a/src/permission/path-restriction.ts +++ b/src/permission/path-restriction.ts @@ -1,6 +1,8 @@ import { realpathSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; import type { RootsProvider } from "./worktree-roots.js"; +import { projectSessionsRoot } from "../session/project-key.js"; // Paths the agent should not touch without explicit operator approval, even // though the read tools are otherwise allow-tier and write/edit auto-allow in @@ -9,10 +11,12 @@ import type { RootsProvider } from "./worktree-roots.js"; // - anything outside the session workspace (the primary cwd and its // registered worktrees) — autonomy is scoped to the workspace boundary, not // the whole filesystem. Restricted for both reads and writes. -// - writes under .agent-state (the agent's own run state) — the agent should -// not rewrite its own session history without operator approval. Reads stay -// unrestricted since .agent-state holds the transcripts users read to debug -// a run. +// - writes under the session state root (global +// ~/.corbits/projects//… and legacy in-repo .agent-state) — +// the agent should not rewrite its own session history without operator +// approval. Reads stay unrestricted since state holds the transcripts +// users read to debug a run. The state root is an exception to the +// outside-workspace rule: global state lives under $HOME, not under cwd. // // Gitignore status is deliberately not a factor: build output, node_modules, // and scratch files are ordinary workspace files for both reads and writes. @@ -21,12 +25,11 @@ import type { RootsProvider } from "./worktree-roots.js"; // require operator approval instead of a hard deny. Results are cached per // resolved path and access mode because the gate consults this on every tool // call with a path argument. - export type PathRestriction = { isRestricted: (path: string, isWrite: boolean) => boolean; }; -const STATE_DIR = ".agent-state"; +const LEGACY_STATE_DIR = ".agent-state"; function realpathOr(path: string): string { try { @@ -83,17 +86,36 @@ export function resolveWorkspacePath( return undefined; } +function underRoot(abs: string, root: string): boolean { + // realpathNearestOr on both sides so a not-yet-created state root still + // compares equal to paths under it (realpathOr alone leaves the root + // unresolved while the abs path is rebuilt through an existing ancestor). + const realRoot = realpathNearestOr(root); + const realAbs = realpathNearestOr(abs); + return realAbs === realRoot || realAbs.startsWith(realRoot + sep); +} + + // `rootsProvider` supplies the additional workspace roots (the session's // registered git worktrees) beyond cwd itself. A worktree created mid-session // is missing from whatever set the provider started with; when a checked path // falls outside every currently-known root, we ask the provider to refresh // once (subject to its own debounce) and re-check before concluding the path // is genuinely outside the workspace. -export function createPathRestriction(cwd: string, rootsProvider: RootsProvider = () => []): PathRestriction { - const stateDir = resolve(cwd, STATE_DIR); +// +// `home` is injectable so tests can pin the global state root without +// mutating process env. +export function createPathRestriction( + cwd: string, + rootsProvider: RootsProvider = () => [], + home: string = homedir(), +): PathRestriction { + const legacyStateDir = resolve(cwd, LEGACY_STATE_DIR); + const globalStateDir = projectSessionsRoot(cwd, home); const cache = new Map(); - const underStateDir = (abs: string): boolean => abs === stateDir || abs.startsWith(stateDir + sep); + const underStateDir = (abs: string): boolean => + underRoot(abs, legacyStateDir) || underRoot(abs, globalStateDir); return { isRestricted: (path: string, isWrite: boolean): boolean => { @@ -101,10 +123,17 @@ export function createPathRestriction(cwd: string, rootsProvider: RootsProvider const cacheKey = `${isWrite ? "w" : "r"}:${abs}`; const cached = cache.get(cacheKey); if (cached !== undefined) return cached; + + // State root: read allow, write ask — even when the root lives outside + // the workspace (global ~/.corbits/projects/...). + if (underStateDir(abs)) { + cache.set(cacheKey, isWrite); + return isWrite; + } + const outsideWorkspace = resolveWorkspacePath(cwd, path, rootsProvider) === undefined; - const restricted = outsideWorkspace || (isWrite && underStateDir(abs)); - cache.set(cacheKey, restricted); - return restricted; + cache.set(cacheKey, outsideWorkspace); + return outsideWorkspace; }, }; } diff --git a/src/permission/store.ts b/src/permission/store.ts index b90e43e78..b0ff83e7e 100644 --- a/src/permission/store.ts +++ b/src/permission/store.ts @@ -10,10 +10,11 @@ import { sessionDir } from "../session/index.js"; import { SETTINGS_DIR_NAME } from "../branding.js"; // Approvals are remembered per session, alongside the run state. -function storePath(cwd: string, sessionId: string): string { - return join(sessionDir(cwd, sessionId), "permissions.json"); +function storePath(cwd: string, sessionId: string, home?: string): string { + return join(sessionDir(cwd, sessionId, home), "permissions.json"); } + // Persistent project grants live next to the project's settings. The file is // gitignored (machine-local), so a teammate who pulls the repo never silently // inherits another machine's auto-approvals. @@ -104,10 +105,15 @@ function chainObjectWrite( return chained; } -export async function loadApprovals(cwd: string, sessionId: string): Promise { - return readApprovalsField(storePath(cwd, sessionId), "approvals"); +export async function loadApprovals( + cwd: string, + sessionId: string, + home?: string, +): Promise { + return readApprovalsField(storePath(cwd, sessionId, home), "approvals"); } + export async function loadProjectApprovals(cwd: string): Promise { return readApprovalsField(projectStorePath(cwd), "approvals"); } diff --git a/src/session/index.ts b/src/session/index.ts index e02443782..8bb51313d 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1,8 +1,13 @@ -import { mkdir, readdir, readlink, stat, symlink, unlink } from "node:fs/promises"; +import { mkdir, readdir, readlink, rename, rm, symlink, stat, cp, unlink } from "node:fs/promises"; +import { existsSync, realpathSync } from "node:fs"; + import { join, dirname } from "node:path"; +import { homedir } from "node:os"; import { loadState, saveState, type RunState } from "./state.js"; import { resolveSessionLabel } from "./session-label.js"; +import { projectRootFor, projectSessionsRoot } from "./project-key.js"; + // --------------------------------------------------------------------------- // UUIDv7 generator (no external dependencies) @@ -47,34 +52,105 @@ export function generateSessionId(): string { // --------------------------------------------------------------------------- // Session directory helpers // --------------------------------------------------------------------------- +// Canonical layout: ~/.corbits/projects/// +// Legacy in-repo layout: /.agent-state// (dual-read + migrate) + +/** Legacy in-repo session base (compat / dual-read only). */ +export const LEGACY_SESSION_BASE = ".agent-state"; + +/** Full path to a session's root directory (canonical global location). */ +export function sessionDir(cwd: string, sessionId: string, home: string = homedir()): string { + return join(projectSessionsRoot(cwd, home), sessionId); +} + +/** Pre-move in-repo path for a session (migration dual-read). */ +export function legacySessionDir(cwd: string, sessionId: string): string { + return join(cwd, LEGACY_SESSION_BASE, sessionId); +} + +/** Candidate legacy session dirs: cwd first, then git project root when different. */ +function legacySessionCandidates(cwd: string, sessionId: string): string[] { + const underCwd = legacySessionDir(cwd, sessionId); + const projectRoot = projectRootFor(cwd); + if (realpathSafe(projectRoot) === realpathSafe(cwd)) return [underCwd]; + return [underCwd, join(projectRoot, LEGACY_SESSION_BASE, sessionId)]; +} + +/** Legacy roots that may still hold unmigrated sessions (cwd + project root). */ +function legacySessionRoots(cwd: string): string[] { + const underCwd = join(cwd, LEGACY_SESSION_BASE); + const projectRoot = projectRootFor(cwd); + if (realpathSafe(projectRoot) === realpathSafe(cwd)) return [underCwd]; + return [underCwd, join(projectRoot, LEGACY_SESSION_BASE)]; +} + +function legacyLatestCandidates(cwd: string): string[] { + return legacySessionRoots(cwd).map((base) => join(base, "latest")); +} + + +function realpathSafe(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * If the global session tree is empty and a legacy in-repo tree exists, move + * it into the global location so resume never strands. Returns the canonical + * session directory path (which may not exist yet for brand-new sessions). + */ +export async function migrateLegacySessionIfNeeded( + cwd: string, + sessionId: string, + home: string = homedir(), +): Promise { + const dir = sessionDir(cwd, sessionId, home); + if (existsSync(dir)) return dir; -const SESSION_BASE = ".agent-state"; + for (const legacy of legacySessionCandidates(cwd, sessionId)) { + if (!existsSync(legacy)) continue; -/** Full path to a session's root directory. */ -export function sessionDir(cwd: string, sessionId: string): string { - return join(cwd, SESSION_BASE, sessionId); + await mkdir(dirname(dir), { recursive: true }); + try { + await rename(legacy, dir); + } catch { + // Cross-device or busy tree: copy then remove legacy. + await cp(legacy, dir, { recursive: true }); + await rm(legacy, { recursive: true, force: true }); + } + return dir; + } + return dir; } + /** Full path to a session's context subdirectory. */ -export function sessionContextDir(cwd: string, sessionId: string): string { - return join(cwd, SESSION_BASE, sessionId, "context"); +export function sessionContextDir(cwd: string, sessionId: string, home: string = homedir()): string { + return join(sessionDir(cwd, sessionId, home), "context"); } -/** Path to the latest-session symlink. */ -function latestSymlinkPath(cwd: string): string { - return join(cwd, SESSION_BASE, "latest"); +/** Path to the latest-session symlink (per project, under the global root). */ +function latestSymlinkPath(cwd: string, home: string = homedir()): string { + return join(projectSessionsRoot(cwd, home), "latest"); } /** * Create a session directory and update the `latest` symlink. * Returns the session directory path. */ -export async function initSessionDir(cwd: string, sessionId: string): Promise { - const dir = sessionDir(cwd, sessionId); +export async function initSessionDir( + cwd: string, + sessionId: string, + home: string = homedir(), +): Promise { + const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home); await mkdir(join(dir, "context"), { recursive: true }); // Update the `latest` symlink to point to this session. - const linkPath = latestSymlinkPath(cwd); + const linkPath = latestSymlinkPath(cwd, home); await mkdir(dirname(linkPath), { recursive: true }); // Remove existing symlink first, then create new one. @@ -90,20 +166,38 @@ export async function initSessionDir(cwd: string, sessionId: string): Promise { try { - const linkPath = latestSymlinkPath(cwd); + const linkPath = latestSymlinkPath(cwd, home); const sessionId = await readlink(linkPath); + await migrateLegacySessionIfNeeded(cwd, sessionId, home); return { sessionId, - dir: sessionDir(cwd, sessionId), - contextDir: sessionContextDir(cwd, sessionId), + dir: sessionDir(cwd, sessionId, home), + contextDir: sessionContextDir(cwd, sessionId, home), }; } catch { + // Fall back: legacy latest under cwd, then under the git project root + // (worktree cwd may not have its own .agent-state/latest). + for (const legacyLink of legacyLatestCandidates(cwd)) { + try { + const sessionId = await readlink(legacyLink); + const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home); + return { + sessionId, + dir, + contextDir: sessionContextDir(cwd, sessionId, home), + }; + } catch { + // try next candidate + } + } return null; } } + export type SessionSummary = { sessionId: string; task: string; @@ -114,20 +208,33 @@ export type SessionSummary = { const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -/** List on-disk sessions for a repo, newest first. */ -export async function listSessions(cwd: string): Promise { - const base = join(cwd, SESSION_BASE); - let entries: string[]; - try { - entries = await readdir(base); - } catch { - return []; +async function collectSessionIds(cwd: string, home: string): Promise { + const ids = new Set(); + const roots = [projectSessionsRoot(cwd, home), ...legacySessionRoots(cwd)]; + for (const base of roots) { + let entries: string[]; + try { + entries = await readdir(base); + } catch { + continue; + } + for (const entry of entries) { + if (entry === "latest" || !SESSION_ID_RE.test(entry)) continue; + ids.add(entry); + } } + return [...ids]; +} + + +/** List on-disk sessions for a project, newest first. */ +export async function listSessions(cwd: string, home: string = homedir()): Promise { + const entries = await collectSessionIds(cwd, home); const summaries: SessionSummary[] = []; for (const entry of entries) { - if (entry === "latest" || !SESSION_ID_RE.test(entry)) continue; - const state = await loadState(cwd, entry); + await migrateLegacySessionIfNeeded(cwd, entry, home); + const state = await loadState(cwd, entry, home); if (state !== null) { summaries.push({ sessionId: entry, @@ -139,8 +246,8 @@ export async function listSessions(cwd: string): Promise { } // TUI sessions persist conversation under context/ before run.json exists. try { - const dirStat = await stat(sessionDir(cwd, entry)); - await stat(sessionContextDir(cwd, entry)); + const dirStat = await stat(sessionDir(cwd, entry, home)); + await stat(sessionContextDir(cwd, entry, home)); summaries.push({ sessionId: entry, task: "(conversation)", @@ -156,22 +263,28 @@ export async function listSessions(cwd: string): Promise { return Promise.all( summaries.map(async (row) => ({ ...row, - task: await resolveSessionLabel(cwd, row.sessionId, row.task), + task: await resolveSessionLabel(cwd, row.sessionId, row.task, home), })), ); } /** Set the display name shown in resume lists and the session header (`run.json` task). */ -export async function renameSession(cwd: string, sessionId: string, name: string): Promise { +export async function renameSession( + cwd: string, + sessionId: string, + name: string, + home: string = homedir(), +): Promise { const trimmed = name.trim(); if (trimmed.length === 0) { throw new Error("Session name cannot be empty"); } - const existing = await loadState(cwd, sessionId); + await migrateLegacySessionIfNeeded(cwd, sessionId, home); + const existing = await loadState(cwd, sessionId, home); if (existing === null) { let startedAt = Date.now(); try { - const dirStat = await stat(sessionDir(cwd, sessionId)); + const dirStat = await stat(sessionDir(cwd, sessionId, home)); startedAt = dirStat.birthtimeMs > 0 ? dirStat.birthtimeMs : dirStat.mtimeMs; } catch { // Session dir missing; fall back to now. @@ -181,8 +294,10 @@ export async function renameSession(cwd: string, sessionId: string, name: string turnsUsed: 0, task: trimmed, startedAt, - }); + }, home); return; } - await saveState(cwd, sessionId, { ...existing, task: trimmed }); + await saveState(cwd, sessionId, { ...existing, task: trimmed }, home); } + +export { projectKeyFor, projectSessionsRoot, projectsRoot, projectRootFor } from "./project-key.js"; diff --git a/src/session/list-sessions.test.ts b/src/session/list-sessions.test.ts index 182843ece..85b10f0a0 100644 --- a/src/session/list-sessions.test.ts +++ b/src/session/list-sessions.test.ts @@ -3,23 +3,28 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { generateSessionId, initSessionDir, listSessions } from "./index.js"; +import { generateSessionId, initSessionDir, listSessions, sessionDir } from "./index.js"; let cwd = ""; +let home = ""; beforeEach(async () => { - cwd = join(tmpdir(), `corbits-list-sessions-${Date.now()}`); + const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + cwd = join(tmpdir(), `corbits-list-sessions-${stamp}`); + home = join(tmpdir(), `corbits-list-home-${stamp}`); await mkdir(cwd, { recursive: true }); + await mkdir(home, { recursive: true }); }); afterEach(async () => { await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); }); test("listSessions includes TUI sessions with context/ but no run.json", async () => { const sessionId = generateSessionId(); - await initSessionDir(cwd, sessionId); - const listed = await listSessions(cwd); + await initSessionDir(cwd, sessionId, home); + const listed = await listSessions(cwd, home); const row = listed.find((s) => s.sessionId === sessionId); expect(row).toBeDefined(); expect(row?.task).toBe("Untitled session"); @@ -27,9 +32,9 @@ test("listSessions includes TUI sessions with context/ but no run.json", async ( test("listSessions prefers run.json task title when present", async () => { const sessionId = generateSessionId(); - await initSessionDir(cwd, sessionId); + await initSessionDir(cwd, sessionId, home); await writeFile( - join(cwd, ".agent-state", sessionId, "run.json"), + join(sessionDir(cwd, sessionId, home), "run.json"), JSON.stringify({ status: "running", turnsUsed: 1, @@ -37,7 +42,7 @@ test("listSessions prefers run.json task title when present", async () => { startedAt: 1_700_000_000_000, }), ); - const listed = await listSessions(cwd); + const listed = await listSessions(cwd, home); const row = listed.find((s) => s.sessionId === sessionId); expect(row?.task).toBe("fix resume"); -}); \ No newline at end of file +}); diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts new file mode 100644 index 000000000..880deb4b9 --- /dev/null +++ b/src/session/project-key.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; + +import { + projectKeyFor, + projectRootFor, + projectSessionsRoot, + projectsRoot, +} from "./project-key.js"; + +let root = ""; + +beforeEach(async () => { + root = join(tmpdir(), `corbits-project-key-${Date.now()}-${Math.random().toString(16).slice(2)}`); + await mkdir(root, { recursive: true }); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("projectKeyFor is stable across calls for the same path", () => { + const a = projectKeyFor(root); + const b = projectKeyFor(root); + expect(a).toBe(b); + expect(a).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*-[a-f0-9]{8}$/); +}); + +test("projectKeyFor uses shared git common dir so worktrees match main", async () => { + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "test"], { cwd: root, stdio: "ignore" }); + await writeFile(join(root, "README"), "x"); + execFileSync("git", ["add", "README"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: root, stdio: "ignore" }); + + const nested = join(root, "nested", "deep"); + await mkdir(nested, { recursive: true }); + expect(projectRootFor(nested)).toBe(projectRootFor(root)); + expect(projectKeyFor(nested)).toBe(projectKeyFor(root)); + + const wt = join(root, "..", `wt-${Date.now()}`); + try { + execFileSync("git", ["worktree", "add", "--detach", wt, "HEAD"], { + cwd: root, + stdio: "ignore", + }); + expect(projectRootFor(wt)).toBe(projectRootFor(root)); + expect(projectKeyFor(wt)).toBe(projectKeyFor(root)); + } finally { + try { + execFileSync("git", ["worktree", "remove", "--force", wt], { + cwd: root, + stdio: "ignore", + }); + } catch { + await rm(wt, { recursive: true, force: true }); + } + } +}); + + +test("projectSessionsRoot lives under ~/.corbits/projects/", () => { + const home = join(root, "home"); + const key = projectKeyFor(root); + expect(projectsRoot(home)).toBe(join(home, ".corbits", "projects")); + expect(projectSessionsRoot(root, home)).toBe(join(home, ".corbits", "projects", key)); +}); diff --git a/src/session/project-key.ts b/src/session/project-key.ts new file mode 100644 index 000000000..a1fd50e23 --- /dev/null +++ b/src/session/project-key.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +import { homedir } from "node:os"; + +import { SETTINGS_DIR_NAME } from "../branding.js"; + +// Project identity for the global session tree under +// ~/.corbits/projects///. Prefer a git *common* root so +// main + linked worktrees share resume history; fall back to the workspace +// realpath for non-git trees. The key is a readable slug plus a short hash of +// the absolute root so common folder names ("src", "app") do not collide. + +function realpathOr(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function slugSegment(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "project"; +} + +/** + * Shared project root for session identity. + * + * Linked worktrees each have their own toplevel path; `--git-common-dir` points + * at the main repo's `.git`, so parent-of-common-dir is stable across worktrees. + */ +export function projectRootFor(cwd: string): string { + try { + const commonRaw = execFileSync( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + if (commonRaw.length > 0) { + const commonAbs = realpathOr( + isAbsolute(commonRaw) ? commonRaw : resolve(cwd, commonRaw), + ); + // Standard layout: /.git → project root is parent. + // Bare repo: common dir is the bare store itself. + const root = + basename(commonAbs) === ".git" ? dirname(commonAbs) : commonAbs; + return realpathOr(root); + } + } catch { + // Not a git worktree (or git unavailable) — use the workspace path. + } + return realpathOr(cwd); +} + +export function projectKeyFor(cwd: string): string { + const root = projectRootFor(cwd); + const base = slugSegment(basename(root)); + const parent = slugSegment(basename(dirname(root))); + const slug = parent === "project" ? base : `${parent}-${base}`; + const hash = createHash("sha256").update(root).digest("hex").slice(0, 8); + return `${slug}-${hash}`; +} + +export function projectsRoot(home: string = homedir()): string { + return join(home, SETTINGS_DIR_NAME, "projects"); +} + +/** Global directory for all sessions of one project. */ +export function projectSessionsRoot(cwd: string, home: string = homedir()): string { + return join(projectsRoot(home), projectKeyFor(cwd)); +} diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index d759aeb36..4841e38c9 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -53,20 +53,26 @@ describe("buildSubAgentProvider", () => { describe("loadSeededApprovals merge order", () => { let cwd = ""; + let home = ""; + let sessionId = ""; afterEach(async () => { if (cwd !== "") await rm(cwd, { recursive: true, force: true }); + if (home !== "") await rm(home, { recursive: true, force: true }); cwd = ""; + home = ""; + sessionId = ""; }); test("orders session, then project, before empty global/provider-model layers", async () => { cwd = await mkdtemp(join(tmpdir(), "runtime-assembly-")); - const sessionId = generateSessionId(); - await initSessionDir(cwd, sessionId); + home = await mkdtemp(join(tmpdir(), "runtime-assembly-home-")); + sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); - await mkdir(sessionDir(cwd, sessionId), { recursive: true }); + await mkdir(sessionDir(cwd, sessionId, home), { recursive: true }); await writeFile( - join(sessionDir(cwd, sessionId), "permissions.json"), + join(sessionDir(cwd, sessionId, home), "permissions.json"), JSON.stringify({ approvals: [{ tool: "run_shell", pattern: "session npm *" }], }), @@ -76,13 +82,14 @@ describe("loadSeededApprovals merge order", () => { pattern: "project npm *", }); - const seeded = await loadSeededApprovals(cwd, sessionId); + const seeded = await loadSeededApprovals(cwd, sessionId, home); // Session must lead so gate first-match prefers the tighter session grant. // Global / provider-model layers may contain real-home entries; assert prefix only. expect(seeded[0]).toEqual({ tool: "run_shell", pattern: "session npm *" }); expect(seeded[1]).toEqual({ tool: "run_shell", pattern: "project npm *" }); }); + }); describe("createApprovalPersist", () => { diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 3e1cf46b6..608132d5d 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -71,8 +71,12 @@ export function buildSubAgentProvider(config: SubAgentProviderConfig): SubAgentP // --------------------------------------------------------------------------- /** Session → project → global → provider-model merge order (first match wins in gate). */ -export async function loadSeededApprovals(cwd: string, sessionId: string): Promise { - const sessionApprovals = await loadApprovals(cwd, sessionId); +export async function loadSeededApprovals( + cwd: string, + sessionId: string, + home?: string, +): Promise { + const sessionApprovals = await loadApprovals(cwd, sessionId, home); const [projectApprovals, globalApprovals, providerModelApprovals] = await Promise.all([ loadProjectApprovals(cwd), loadGlobalApprovals(), @@ -86,6 +90,7 @@ export async function loadSeededApprovals(cwd: string, sessionId: string): Promi ]; } + /** * Route a gate-persisted grant to the store its scope selects. * Session grants never reach here — the gate keeps those in memory only. diff --git a/src/session/sent-messages.test.ts b/src/session/sent-messages.test.ts index f703d945f..0aaeb0bc1 100644 --- a/src/session/sent-messages.test.ts +++ b/src/session/sent-messages.test.ts @@ -8,33 +8,37 @@ import { appendSentMessage, loadSentMessages } from "./sent-messages.js"; describe("sent-messages", () => { let cwd: string; + let home: string; let sessionId: string; afterEach(async () => { if (cwd !== undefined) await rm(cwd, { recursive: true, force: true }); + if (home !== undefined) await rm(home, { recursive: true, force: true }); }); test("append and load per session", async () => { cwd = await mkdtemp(join(tmpdir(), "sent-msg-")); + home = await mkdtemp(join(tmpdir(), "sent-msg-home-")); sessionId = generateSessionId(); - await initSessionDir(cwd, sessionId); + await initSessionDir(cwd, sessionId, home); - expect(await loadSentMessages(cwd, sessionId)).toEqual([]); - await appendSentMessage(cwd, sessionId, " hello "); - await appendSentMessage(cwd, sessionId, "world"); - expect(await loadSentMessages(cwd, sessionId)).toEqual(["hello", "world"]); + expect(await loadSentMessages(cwd, sessionId, home)).toEqual([]); + await appendSentMessage(cwd, sessionId, " hello ", home); + await appendSentMessage(cwd, sessionId, "world", home); + expect(await loadSentMessages(cwd, sessionId, home)).toEqual(["hello", "world"]); }); test("load keeps only the last 20 messages", async () => { cwd = await mkdtemp(join(tmpdir(), "sent-msg-")); + home = await mkdtemp(join(tmpdir(), "sent-msg-home-")); sessionId = generateSessionId(); - await initSessionDir(cwd, sessionId); + await initSessionDir(cwd, sessionId, home); for (let i = 0; i < 25; i++) { - await appendSentMessage(cwd, sessionId, `msg-${i}`); + await appendSentMessage(cwd, sessionId, `msg-${i}`, home); } - const loaded = await loadSentMessages(cwd, sessionId); + const loaded = await loadSentMessages(cwd, sessionId, home); expect(loaded).toHaveLength(20); expect(loaded[0]).toBe("msg-5"); expect(loaded[19]).toBe("msg-24"); }); -}); \ No newline at end of file +}); diff --git a/src/session/sent-messages.ts b/src/session/sent-messages.ts index a23d235ff..3511f2948 100644 --- a/src/session/sent-messages.ts +++ b/src/session/sent-messages.ts @@ -14,14 +14,20 @@ export const SENT_MESSAGE_HISTORY_LIMIT = 20; // head simply fails to parse and is skipped. const TAIL_BYTES = 64_000; -function sentMessagesPath(cwd: string, sessionId: string): string { - return join(sessionDir(cwd, sessionId), "sent-messages.ndjson"); +function sentMessagesPath(cwd: string, sessionId: string, home?: string): string { + return join(sessionDir(cwd, sessionId, home), "sent-messages.ndjson"); } + const sentMessage = type("string"); -export async function loadSentMessages(cwd: string, sessionId: string): Promise { - const file = Bun.file(sentMessagesPath(cwd, sessionId)); +export async function loadSentMessages( + cwd: string, + sessionId: string, + home?: string, +): Promise { + const file = Bun.file(sentMessagesPath(cwd, sessionId, home)); + let raw: string; try { const size = file.size; @@ -44,8 +50,14 @@ export async function loadSentMessages(cwd: string, sessionId: string): Promise< } // Append-only: no read-modify-write race. Each message is one JSON line. -export async function appendSentMessage(cwd: string, sessionId: string, message: string): Promise { +export async function appendSentMessage( + cwd: string, + sessionId: string, + message: string, + home?: string, +): Promise { const trimmed = message.trim(); if (trimmed.length === 0) return; - await appendFile(sentMessagesPath(cwd, sessionId), JSON.stringify(trimmed) + "\n"); + await appendFile(sentMessagesPath(cwd, sessionId, home), JSON.stringify(trimmed) + "\n"); } + diff --git a/src/session/session-dir.test.ts b/src/session/session-dir.test.ts new file mode 100644 index 000000000..45e3fb42b --- /dev/null +++ b/src/session/session-dir.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + generateSessionId, + initSessionDir, + legacySessionDir, + listSessions, + migrateLegacySessionIfNeeded, + sessionDir, +} from "./index.js"; + +let cwd = ""; +let home = ""; + +beforeEach(async () => { + const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + cwd = join(tmpdir(), `corbits-session-dir-${stamp}`); + home = join(tmpdir(), `corbits-session-home-${stamp}`); + await mkdir(cwd, { recursive: true }); + await mkdir(home, { recursive: true }); +}); + +afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); +}); + +test("initSessionDir writes under the global projects tree, not the repo", async () => { + const sessionId = generateSessionId(); + const dir = await initSessionDir(cwd, sessionId, home); + expect(dir).toBe(sessionDir(cwd, sessionId, home)); + expect(dir.startsWith(join(home, ".corbits", "projects"))).toBe(true); + expect(existsSync(join(dir, "context"))).toBe(true); + expect(existsSync(join(cwd, ".agent-state", sessionId))).toBe(false); +}); + +test("migrateLegacySessionIfNeeded moves in-repo sessions into the global tree", async () => { + const sessionId = generateSessionId(); + const legacy = legacySessionDir(cwd, sessionId); + await mkdir(join(legacy, "context"), { recursive: true }); + await writeFile( + join(legacy, "run.json"), + JSON.stringify({ + status: "running", + turnsUsed: 2, + task: "legacy task", + startedAt: 1_700_000_000_000, + }), + ); + + const dir = await migrateLegacySessionIfNeeded(cwd, sessionId, home); + expect(dir).toBe(sessionDir(cwd, sessionId, home)); + expect(existsSync(dir)).toBe(true); + expect(existsSync(legacy)).toBe(false); + const raw = await readFile(join(dir, "run.json"), "utf8"); + expect(JSON.parse(raw).task).toBe("legacy task"); +}); + +test("listSessions finds legacy sessions and migrates them", async () => { + const sessionId = generateSessionId(); + const legacy = legacySessionDir(cwd, sessionId); + await mkdir(join(legacy, "context"), { recursive: true }); + await writeFile( + join(legacy, "run.json"), + JSON.stringify({ + status: "done", + turnsUsed: 1, + task: "from legacy", + startedAt: 1_700_000_000_000, + }), + ); + + const listed = await listSessions(cwd, home); + const row = listed.find((s) => s.sessionId === sessionId); + expect(row?.task).toBe("from legacy"); + expect(existsSync(sessionDir(cwd, sessionId, home))).toBe(true); + expect(existsSync(legacy)).toBe(false); +}); + +test("migrateLegacySessionIfNeeded finds legacy under git project root from a worktree cwd", async () => { + const { execFileSync } = await import("node:child_process"); + const main = join(cwd, "main"); + await mkdir(main, { recursive: true }); + execFileSync("git", ["init"], { cwd: main, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: main, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "test"], { cwd: main, stdio: "ignore" }); + await writeFile(join(main, "README"), "x"); + execFileSync("git", ["add", "README"], { cwd: main, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: main, stdio: "ignore" }); + + const sessionId = generateSessionId(); + const legacyOnMain = join(main, ".agent-state", sessionId); + await mkdir(join(legacyOnMain, "context"), { recursive: true }); + await writeFile( + join(legacyOnMain, "run.json"), + JSON.stringify({ + status: "running", + turnsUsed: 1, + task: "main-legacy", + startedAt: 1_700_000_000_000, + }), + ); + + const wt = join(cwd, "wt"); + execFileSync("git", ["worktree", "add", "--detach", wt, "HEAD"], { + cwd: main, + stdio: "ignore", + }); + try { + const dir = await migrateLegacySessionIfNeeded(wt, sessionId, home); + expect(dir).toBe(sessionDir(wt, sessionId, home)); + expect(existsSync(dir)).toBe(true); + expect(existsSync(legacyOnMain)).toBe(false); + const raw = await readFile(join(dir, "run.json"), "utf8"); + expect(JSON.parse(raw).task).toBe("main-legacy"); + } finally { + try { + execFileSync("git", ["worktree", "remove", "--force", wt], { + cwd: main, + stdio: "ignore", + }); + } catch { + await rm(wt, { recursive: true, force: true }); + } + } +}); + diff --git a/src/session/session-label.test.ts b/src/session/session-label.test.ts index f5ae694bb..da5030572 100644 --- a/src/session/session-label.test.ts +++ b/src/session/session-label.test.ts @@ -8,14 +8,19 @@ import { appendSentMessage } from "./sent-messages.js"; import { isGenericSessionTask, resolveSessionLabel, truncateSessionLabel } from "./session-label.js"; let cwd = ""; +let home = ""; beforeEach(async () => { - cwd = join(tmpdir(), `corbits-session-label-${Date.now()}`); + const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + cwd = join(tmpdir(), `corbits-session-label-${stamp}`); + home = join(tmpdir(), `corbits-session-label-home-${stamp}`); await mkdir(cwd, { recursive: true }); + await mkdir(home, { recursive: true }); }); afterEach(async () => { await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); }); test("truncateSessionLabel collapses whitespace", () => { @@ -24,20 +29,20 @@ test("truncateSessionLabel collapses whitespace", () => { test("resolveSessionLabel uses run.json task when set", async () => { const id = generateSessionId(); - await initSessionDir(cwd, id); - const label = await resolveSessionLabel(cwd, id, "Ship sent-history"); + await initSessionDir(cwd, id, home); + const label = await resolveSessionLabel(cwd, id, "Ship sent-history", home); expect(label).toBe("Ship sent-history"); }); test("resolveSessionLabel falls back to first sent message", async () => { const id = generateSessionId(); - await initSessionDir(cwd, id); - await appendSentMessage(cwd, id, "How do we name sessions?"); - const label = await resolveSessionLabel(cwd, id, "(conversation)"); + await initSessionDir(cwd, id, home); + await appendSentMessage(cwd, id, "How do we name sessions?", home); + const label = await resolveSessionLabel(cwd, id, "(conversation)", home); expect(label).toBe("How do we name sessions?"); }); test("isGenericSessionTask", () => { expect(isGenericSessionTask("(conversation)")).toBe(true); expect(isGenericSessionTask("Real title")).toBe(false); -}); \ No newline at end of file +}); diff --git a/src/session/session-label.ts b/src/session/session-label.ts index 97594d697..32a955fa4 100644 --- a/src/session/session-label.ts +++ b/src/session/session-label.ts @@ -17,15 +17,16 @@ export async function resolveSessionLabel( cwd: string, sessionId: string, taskFromState: string, + home?: string, ): Promise { const trimmed = taskFromState.trim(); if (!isGenericSessionTask(trimmed)) { return truncateSessionLabel(trimmed); } - const sent = await loadSentMessages(cwd, sessionId); + const sent = await loadSentMessages(cwd, sessionId, home); const first = sent.find((line) => line.trim().length > 0); if (first !== undefined) { return truncateSessionLabel(first); } return "Untitled session"; -} \ No newline at end of file +} diff --git a/src/session/state.ts b/src/session/state.ts index 1e5ffc7be..4da31c630 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -31,10 +31,11 @@ const RunStateSchema = type({ export type RunState = typeof RunStateSchema.infer; -function statePath(cwd: string, sessionId: string): string { - return join(sessionDir(cwd, sessionId), "run.json"); +function statePath(cwd: string, sessionId: string, home?: string): string { + return join(sessionDir(cwd, sessionId, home), "run.json"); } + let tmpWriteCounter = 0; // Write atomically: serialize to a unique temp file, then rename into place so a @@ -54,10 +55,16 @@ export function warnUnreadableState(path: string, reason: string): void { process.stderr.write(`${COMMAND_NAME}: ignoring unreadable state at ${path} (${reason}); starting fresh\n`); } -export async function saveState(cwd: string, sessionId: string, state: RunState): Promise { - await atomicWrite(statePath(cwd, sessionId), JSON.stringify(state, null, 2)); +export async function saveState( + cwd: string, + sessionId: string, + state: RunState, + home?: string, +): Promise { + await atomicWrite(statePath(cwd, sessionId, home), JSON.stringify(state, null, 2)); } + // Returns the parsed state, or the arktype error summary when the shape is // invalid, so callers can surface a specific reason rather than "invalid shape". function parseRunState(data: unknown): RunState | { error: string } { @@ -65,8 +72,13 @@ function parseRunState(data: unknown): RunState | { error: string } { return result instanceof type.errors ? { error: result.summary } : result; } -export async function loadState(cwd: string, sessionId: string): Promise { - const path = statePath(cwd, sessionId); +export async function loadState( + cwd: string, + sessionId: string, + home?: string, +): Promise { + const path = statePath(cwd, sessionId, home); + try { const raw = await readFile(path, "utf8"); const parsed = parseRunState(JSON.parse(raw)); diff --git a/src/state.test.ts b/src/state.test.ts index 98c66a946..c48823ab8 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -7,11 +7,12 @@ import { loadState, type RunState, } from "./session/state.js"; +import { sessionDir } from "./session/index.js"; const SESSION_ID = "test-session-001"; -async function makeTempDir(): Promise { - return mkdtemp(join(tmpdir(), "state-test-")); +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)); } const baseRunState: RunState = { @@ -23,22 +24,27 @@ const baseRunState: RunState = { describe("state persistence", () => { let cwd: string; + let home: string; beforeEach(async () => { - cwd = await makeTempDir(); + cwd = await makeTempDir("state-test-cwd-"); + home = await makeTempDir("state-test-home-"); }); afterEach(async () => { await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); }); + const dir = () => sessionDir(cwd, SESSION_ID, home); + // --------------------------------------------------------------------------- // 1. Round-trip: save then load returns an equal object // --------------------------------------------------------------------------- test("saveState then loadState returns an equal RunState", async () => { - await saveState(cwd, SESSION_ID, baseRunState); - const loaded = await loadState(cwd, SESSION_ID); + await saveState(cwd, SESSION_ID, baseRunState, home); + const loaded = await loadState(cwd, SESSION_ID, home); expect(loaded).toEqual(baseRunState); }); @@ -48,8 +54,8 @@ describe("state persistence", () => { status: "done", finishedAt: 1_700_000_005_000, }; - await saveState(cwd, SESSION_ID, state); - const loaded = await loadState(cwd, SESSION_ID); + await saveState(cwd, SESSION_ID, state, home); + const loaded = await loadState(cwd, SESSION_ID, home); expect(loaded).toEqual(state); }); @@ -58,23 +64,21 @@ describe("state persistence", () => { // --------------------------------------------------------------------------- test("loadState on missing file returns null", async () => { - const result = await loadState(cwd, "nonexistent-session"); + const result = await loadState(cwd, "nonexistent-session", home); expect(result).toBeNull(); }); // --------------------------------------------------------------------------- // 3. Corrupt / truncated JSON returns null rather than throwing - // BUG: JSON.parse throws SyntaxError (no .code property), so the catch - // block re-throws it. The fix is to catch SyntaxError and return null. // --------------------------------------------------------------------------- test("loadState with truncated JSON returns null instead of throwing", async () => { - const stateDir = join(cwd, ".agent-state", SESSION_ID); + const stateDir = dir(); const { mkdir } = await import("node:fs/promises"); await mkdir(stateDir, { recursive: true }); await writeFile(join(stateDir, "run.json"), '{ "turnsUsed": '); - const result = await loadState(cwd, SESSION_ID); + const result = await loadState(cwd, SESSION_ID, home); expect(result).toBeNull(); }); @@ -83,7 +87,7 @@ describe("state persistence", () => { // --------------------------------------------------------------------------- test("loadState with turnsUsed as string returns null", async () => { - const stateDir = join(cwd, ".agent-state", SESSION_ID); + const stateDir = dir(); const { mkdir } = await import("node:fs/promises"); await mkdir(stateDir, { recursive: true }); await writeFile( @@ -91,25 +95,17 @@ describe("state persistence", () => { JSON.stringify({ status: "running", turnsUsed: "not-a-number", task: "x", startedAt: 0 }), ); - const result = await loadState(cwd, SESSION_ID); + const result = await loadState(cwd, SESSION_ID, home); expect(result).toBeNull(); }); // --------------------------------------------------------------------------- // 5. Atomic write: saveState uses temp+rename - // BUG: saveState called writeFile directly to the final path, so a process - // killed mid-write would leave torn JSON. Fixed: write to a .tmp file then - // rename into place, matching the approvals-store pattern. - // - // Direct interception of the bound `rename` import is not possible from the - // test. Instead, we verify observable post-conditions: the canonical path - // contains well-formed JSON after the call, no temp file remains, and a - // pre-existing file at the canonical path is fully replaced (not torn). // --------------------------------------------------------------------------- test("saveState leaves no .tmp file after successful write", async () => { - await saveState(cwd, SESSION_ID, baseRunState); - const stateDir = join(cwd, ".agent-state", SESSION_ID); + await saveState(cwd, SESSION_ID, baseRunState, home); + const stateDir = dir(); const { readdir } = await import("node:fs/promises"); const files = await readdir(stateDir); const temps = files.filter((f) => f.includes(".tmp")); @@ -117,20 +113,17 @@ describe("state persistence", () => { }); test("saveState overwrites a pre-existing file with well-formed JSON", async () => { - // Write a known good file, then overwrite — simulates repeated saves. - await saveState(cwd, SESSION_ID, baseRunState); + await saveState(cwd, SESSION_ID, baseRunState, home); const updated: RunState = { ...baseRunState, turnsUsed: 99, status: "done" }; - await saveState(cwd, SESSION_ID, updated); - const raw = await readFile(join(cwd, ".agent-state", SESSION_ID, "run.json"), "utf8"); - // The canonical path must contain only the new payload, never a partial mix. + await saveState(cwd, SESSION_ID, updated, home); + const raw = await readFile(join(dir(), "run.json"), "utf8"); expect(() => JSON.parse(raw)).not.toThrow(); expect(JSON.parse(raw)).toEqual(updated); }); test("saveState produces a valid final file that round-trips", async () => { - // Rename completed — file is well-formed JSON at the canonical path. - await saveState(cwd, SESSION_ID, baseRunState); - const raw = await readFile(join(cwd, ".agent-state", SESSION_ID, "run.json"), "utf8"); + await saveState(cwd, SESSION_ID, baseRunState, home); + const raw = await readFile(join(dir(), "run.json"), "utf8"); expect(() => JSON.parse(raw)).not.toThrow(); expect(JSON.parse(raw)).toEqual(baseRunState); }); @@ -148,20 +141,20 @@ describe("state persistence", () => { { name: "railway", toolCount: 4 }, ], }; - await saveState(cwd, SESSION_ID, state); - const loaded = await loadState(cwd, SESSION_ID); + await saveState(cwd, SESSION_ID, state, home); + const loaded = await loadState(cwd, SESSION_ID, home); expect(loaded).toEqual(state); }); test("loadState accepts a record with no model or mcpServers (pre-existing sessions)", async () => { - await saveState(cwd, SESSION_ID, baseRunState); - const loaded = await loadState(cwd, SESSION_ID); + await saveState(cwd, SESSION_ID, baseRunState, home); + const loaded = await loadState(cwd, SESSION_ID, home); expect(loaded?.model).toBeUndefined(); expect(loaded?.mcpServers).toBeUndefined(); }); test("loadState rejects a mcpServers entry missing toolCount", async () => { - const stateDir = join(cwd, ".agent-state", SESSION_ID); + const stateDir = dir(); const { mkdir } = await import("node:fs/promises"); await mkdir(stateDir, { recursive: true }); await writeFile( @@ -175,24 +168,7 @@ describe("state persistence", () => { }), ); - const result = await loadState(cwd, SESSION_ID); + const result = await loadState(cwd, SESSION_ID, home); expect(result).toBeNull(); }); - - // --------------------------------------------------------------------------- - // 7. "cancelled" is a valid terminal status distinct from "running" - // --------------------------------------------------------------------------- - - test("saveState round-trips a cancelled status with finishedAt set", async () => { - const state: RunState = { - ...baseRunState, - status: "cancelled", - finishedAt: 1_700_000_010_000, - }; - await saveState(cwd, SESSION_ID, state); - const loaded = await loadState(cwd, SESSION_ID); - expect(loaded).toEqual(state); - expect(loaded?.status).not.toBe("running"); - }); - }); diff --git a/src/workflows/state.ts b/src/workflows/state.ts index 470aa808e..157dff19c 100644 --- a/src/workflows/state.ts +++ b/src/workflows/state.ts @@ -10,10 +10,11 @@ const STEP_STATUSES: StepStatus[] = ["pending", "active", "completed", "skipped" const writeChains = new Map>(); -function workflowStatePath(cwd: string, sessionId: string): string { - return join(sessionDir(cwd, sessionId), "workflow.json"); +function workflowStatePath(cwd: string, sessionId: string, home?: string): string { + return join(sessionDir(cwd, sessionId, home), "workflow.json"); } + function isValidWorkflowState(data: unknown): data is WorkflowState { if (typeof data !== "object" || data === null) return false; const s = data as Record; @@ -45,8 +46,9 @@ export async function saveWorkflowState( cwd: string, sessionId: string, state: WorkflowState, + home?: string, ): Promise { - const path = workflowStatePath(cwd, sessionId); + const path = workflowStatePath(cwd, sessionId, home); const payload = JSON.stringify(state, null, 2); const run = (): Promise => atomicWrite(path, payload); const chained = (writeChains.get(path) ?? Promise.resolve()).then(run, run); @@ -55,16 +57,22 @@ export async function saveWorkflowState( } /** Await any in-flight save for this session (used by tests and shutdown paths). */ -export async function flushWorkflowStateWrites(cwd: string, sessionId: string): Promise { - const path = workflowStatePath(cwd, sessionId); +export async function flushWorkflowStateWrites( + cwd: string, + sessionId: string, + home?: string, +): Promise { + const path = workflowStatePath(cwd, sessionId, home); await (writeChains.get(path) ?? Promise.resolve()); } export async function loadWorkflowState( cwd: string, sessionId: string, + home?: string, ): Promise { - const path = workflowStatePath(cwd, sessionId); + const path = workflowStatePath(cwd, sessionId, home); + try { const raw = await readFile(path, "utf8"); const parsed = JSON.parse(raw); diff --git a/tests/unit/workflow-controller.test.ts b/tests/unit/workflow-controller.test.ts index 3a9672ae4..ae1f78b96 100644 --- a/tests/unit/workflow-controller.test.ts +++ b/tests/unit/workflow-controller.test.ts @@ -25,7 +25,8 @@ async function withController( ) => void | Promise, ): Promise { const cwd = await mkdtemp(join(tmpdir(), "wf-controller-")); - await initSessionDir(cwd, "session-1"); + const home = await mkdtemp(join(tmpdir(), "wf-controller-home-")); + await initSessionDir(cwd, "session-1", home); const director = { coordinator: undefined as WorkflowCoordinator | undefined }; const controller = new WorkflowController({ cwd, @@ -41,11 +42,13 @@ async function withController( try { await fn(controller, director, cwd); } finally { - await flushWorkflowStateWrites(cwd, "session-1"); + await flushWorkflowStateWrites(cwd, "session-1", home); await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); } } + test("starting a workflow attaches a coordinator to the director", async () => { await withController([], async (controller, director, _cwd) => { const msg = controller.start("review"); diff --git a/tests/unit/workflows-runtime-persistence.test.ts b/tests/unit/workflows-runtime-persistence.test.ts index 96f9b1584..dcc06522e 100644 --- a/tests/unit/workflows-runtime-persistence.test.ts +++ b/tests/unit/workflows-runtime-persistence.test.ts @@ -10,6 +10,7 @@ import { loadWorkflowState, saveWorkflowState } from "../../src/workflows/state. test("WorkflowRuntime resumes from workflow.json written mid sub-workflow chain", async () => { const cwd = await mkdtemp(join(tmpdir(), "wf-runtime-persist-")); + const home = await mkdtemp(join(tmpdir(), "wf-runtime-persist-home-")); try { const build = findWorkflow("build"); expect(build).toBeDefined(); @@ -23,8 +24,8 @@ test("WorkflowRuntime resumes from workflow.json written mid sub-workflow chain" expect(mid).toBeDefined(); expect(mid).not.toBe(first); - await saveWorkflowState(cwd, "session-1", runtime.state()); - const loaded = await loadWorkflowState(cwd, "session-1"); + await saveWorkflowState(cwd, "session-1", runtime.state(), home); + const loaded = await loadWorkflowState(cwd, "session-1", home); expect(loaded).toEqual(runtime.state()); const resumed = new WorkflowRuntime(new Map()); @@ -34,5 +35,6 @@ test("WorkflowRuntime resumes from workflow.json written mid sub-workflow chain" expect(resumed.isActive()).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); } }); diff --git a/tests/unit/workflows-state.test.ts b/tests/unit/workflows-state.test.ts index 7a2f425a2..8d61557ea 100644 --- a/tests/unit/workflows-state.test.ts +++ b/tests/unit/workflows-state.test.ts @@ -18,35 +18,38 @@ const sampleState: WorkflowState = { describe("workflow state persistence", () => { let cwd: string; + let home: string; beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "wf-state-")); + home = await mkdtemp(join(tmpdir(), "wf-state-home-")); }); afterEach(async () => { await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); }); test("saveWorkflowState then loadWorkflowState returns an equal object", async () => { - await saveWorkflowState(cwd, SESSION_ID, sampleState); - const loaded = await loadWorkflowState(cwd, SESSION_ID); + await saveWorkflowState(cwd, SESSION_ID, sampleState, home); + const loaded = await loadWorkflowState(cwd, SESSION_ID, home); expect(loaded).toEqual(sampleState); }); test("loadWorkflowState on missing file returns null", async () => { - expect(await loadWorkflowState(cwd, "nope")).toBeNull(); + expect(await loadWorkflowState(cwd, "nope", home)).toBeNull(); }); test("loadWorkflowState with truncated JSON returns null instead of throwing", async () => { - const dir = sessionDir(cwd, SESSION_ID); + const dir = sessionDir(cwd, SESSION_ID, home); await mkdir(dir, { recursive: true }); await writeFile(join(dir, "workflow.json"), '{ "completed": false, "stack": ['); - expect(await loadWorkflowState(cwd, SESSION_ID)).toBeNull(); + expect(await loadWorkflowState(cwd, SESSION_ID, home)).toBeNull(); }); test("loadWorkflowState rejects invalid stepIndex values", async () => { - const dir = sessionDir(cwd, SESSION_ID); + const dir = sessionDir(cwd, SESSION_ID, home); await mkdir(dir, { recursive: true }); await writeFile( join(dir, "workflow.json"), @@ -56,11 +59,11 @@ describe("workflow state persistence", () => { }), ); - expect(await loadWorkflowState(cwd, SESSION_ID)).toBeNull(); + expect(await loadWorkflowState(cwd, SESSION_ID, home)).toBeNull(); }); test("loadWorkflowState rejects unknown step statuses", async () => { - const dir = sessionDir(cwd, SESSION_ID); + const dir = sessionDir(cwd, SESSION_ID, home); await mkdir(dir, { recursive: true }); await writeFile( join(dir, "workflow.json"), @@ -70,30 +73,30 @@ describe("workflow state persistence", () => { }), ); - expect(await loadWorkflowState(cwd, SESSION_ID)).toBeNull(); + expect(await loadWorkflowState(cwd, SESSION_ID, home)).toBeNull(); }); test("saveWorkflowState leaves no .tmp file after successful write", async () => { - await saveWorkflowState(cwd, SESSION_ID, sampleState); - const files = await readdir(sessionDir(cwd, SESSION_ID)); + await saveWorkflowState(cwd, SESSION_ID, sampleState, home); + const files = await readdir(sessionDir(cwd, SESSION_ID, home)); expect(files.filter((f) => f.includes(".tmp"))).toHaveLength(0); }); test("saveWorkflowState overwrites a pre-existing file with well-formed JSON", async () => { - await saveWorkflowState(cwd, SESSION_ID, sampleState); + await saveWorkflowState(cwd, SESSION_ID, sampleState, home); const updated: WorkflowState = { ...sampleState, completed: true, stack: [] }; - await saveWorkflowState(cwd, SESSION_ID, updated); - const raw = await readFile(join(sessionDir(cwd, SESSION_ID), "workflow.json"), "utf8"); + await saveWorkflowState(cwd, SESSION_ID, updated, home); + const raw = await readFile(join(sessionDir(cwd, SESSION_ID, home), "workflow.json"), "utf8"); expect(JSON.parse(raw)).toEqual(updated); }); test("concurrent saveWorkflowState calls serialize and leave valid JSON", async () => { await Promise.all([ - saveWorkflowState(cwd, SESSION_ID, sampleState), - saveWorkflowState(cwd, SESSION_ID, { ...sampleState, completed: true }), - saveWorkflowState(cwd, SESSION_ID, sampleState), + saveWorkflowState(cwd, SESSION_ID, sampleState, home), + saveWorkflowState(cwd, SESSION_ID, { ...sampleState, completed: true }, home), + saveWorkflowState(cwd, SESSION_ID, sampleState, home), ]); - const loaded = await loadWorkflowState(cwd, SESSION_ID); + const loaded = await loadWorkflowState(cwd, SESSION_ID, home); expect(loaded).not.toBeNull(); expect(loaded?.stack).toEqual(sampleState.stack); });