From c5c3316e4923ebea9606209ab52f75cb5e02ca9e Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 25 Aug 2026 16:07:31 -0400 Subject: [PATCH] add persisted pi workflow extension --- config/pi-links.json | 1 + ...0001-pi-workflow-architecture-24AUG2026.md | 19 + docs/pi-workflow/README.md | 68 + docs/pi-workflow/dashboard-mocks.md | 141 + docs/pi-workflow/design.md | 679 +++ extensions/_workflows/runner.ts | 82 +- .../pi-workflow/agents/feature-designer.md | 16 + .../pi-workflow/agents/feature-implementer.md | 16 + .../pi-workflow/agents/feature-verifier.md | 16 + .../pi-workflow/agents/general-discovery.md | 16 + .../agents/independent-reviewer.md | 16 + .../pi-workflow/agents/ui-prototyper.md | 16 + extensions/pi-workflow/config.test.ts | 178 + extensions/pi-workflow/config.ts | 256 + .../pi-workflow/context-transition.test.ts | 509 ++ extensions/pi-workflow/context-transition.ts | 399 ++ extensions/pi-workflow/continuation.test.ts | 94 + extensions/pi-workflow/continuation.ts | 65 + extensions/pi-workflow/dashboard.test.ts | 384 ++ extensions/pi-workflow/dashboard.ts | 557 ++ extensions/pi-workflow/domain.ts | 465 ++ extensions/pi-workflow/engine.test.ts | 3502 ++++++++++++ extensions/pi-workflow/engine.ts | 4684 +++++++++++++++++ extensions/pi-workflow/execution.test.ts | 862 +++ extensions/pi-workflow/execution.ts | 817 +++ extensions/pi-workflow/index.test.ts | 39 + extensions/pi-workflow/index.ts | 123 + extensions/pi-workflow/notifications.test.ts | 286 + extensions/pi-workflow/notifications.ts | 207 + extensions/pi-workflow/profiles.test.ts | 494 ++ extensions/pi-workflow/profiles.ts | 663 +++ extensions/pi-workflow/sandbox-child.cjs | 280 + extensions/pi-workflow/script-runtime.test.ts | 145 + extensions/pi-workflow/script-runtime.ts | 339 ++ extensions/pi-workflow/store.test.ts | 512 ++ extensions/pi-workflow/store.ts | 924 ++++ extensions/pi-workflow/tool.test.ts | 296 ++ extensions/pi-workflow/tool.ts | 496 ++ extensions/pi-workflow/ui-policy.test.ts | 90 + extensions/pi-workflow/ui-policy.ts | 93 + extensions/pi-workflow/workspace.test.ts | 671 +++ extensions/pi-workflow/workspace.ts | 1107 ++++ extensions/shared/child-session.test.ts | 8 + extensions/shared/child-session.ts | 8 +- extensions/subagents/src/backends/pi.ts | 91 +- package.json | 6 +- 46 files changed, 20642 insertions(+), 94 deletions(-) create mode 100644 docs/adr/0001-pi-workflow-architecture-24AUG2026.md create mode 100644 docs/pi-workflow/README.md create mode 100644 docs/pi-workflow/dashboard-mocks.md create mode 100644 docs/pi-workflow/design.md create mode 100644 extensions/pi-workflow/agents/feature-designer.md create mode 100644 extensions/pi-workflow/agents/feature-implementer.md create mode 100644 extensions/pi-workflow/agents/feature-verifier.md create mode 100644 extensions/pi-workflow/agents/general-discovery.md create mode 100644 extensions/pi-workflow/agents/independent-reviewer.md create mode 100644 extensions/pi-workflow/agents/ui-prototyper.md create mode 100644 extensions/pi-workflow/config.test.ts create mode 100644 extensions/pi-workflow/config.ts create mode 100644 extensions/pi-workflow/context-transition.test.ts create mode 100644 extensions/pi-workflow/context-transition.ts create mode 100644 extensions/pi-workflow/continuation.test.ts create mode 100644 extensions/pi-workflow/continuation.ts create mode 100644 extensions/pi-workflow/dashboard.test.ts create mode 100644 extensions/pi-workflow/dashboard.ts create mode 100644 extensions/pi-workflow/domain.ts create mode 100644 extensions/pi-workflow/engine.test.ts create mode 100644 extensions/pi-workflow/engine.ts create mode 100644 extensions/pi-workflow/execution.test.ts create mode 100644 extensions/pi-workflow/execution.ts create mode 100644 extensions/pi-workflow/index.test.ts create mode 100644 extensions/pi-workflow/index.ts create mode 100644 extensions/pi-workflow/notifications.test.ts create mode 100644 extensions/pi-workflow/notifications.ts create mode 100644 extensions/pi-workflow/profiles.test.ts create mode 100644 extensions/pi-workflow/profiles.ts create mode 100644 extensions/pi-workflow/sandbox-child.cjs create mode 100644 extensions/pi-workflow/script-runtime.test.ts create mode 100644 extensions/pi-workflow/script-runtime.ts create mode 100644 extensions/pi-workflow/store.test.ts create mode 100644 extensions/pi-workflow/store.ts create mode 100644 extensions/pi-workflow/tool.test.ts create mode 100644 extensions/pi-workflow/tool.ts create mode 100644 extensions/pi-workflow/ui-policy.test.ts create mode 100644 extensions/pi-workflow/ui-policy.ts create mode 100644 extensions/pi-workflow/workspace.test.ts create mode 100644 extensions/pi-workflow/workspace.ts diff --git a/config/pi-links.json b/config/pi-links.json index 45dbb43..ebdc192 100644 --- a/config/pi-links.json +++ b/config/pi-links.json @@ -23,6 +23,7 @@ "extensions": { "globalSymlinks": { "effort-controls": "extensions/effort-controls", + "pi-workflow": "extensions/pi-workflow", "update": "extensions/update" }, "globalLocalPackages": {} diff --git a/docs/adr/0001-pi-workflow-architecture-24AUG2026.md b/docs/adr/0001-pi-workflow-architecture-24AUG2026.md new file mode 100644 index 0000000..ca4f925 --- /dev/null +++ b/docs/adr/0001-pi-workflow-architecture-24AUG2026.md @@ -0,0 +1,19 @@ +--- +status: accepted +--- + +# Build pi-workflow around persisted policy gates + +`pi-workflow` will be a new extension rather than an activation or rename of `_workflows`. It will expose one command-style tool backed by atomic revisioned snapshots, Markdown Agent Profiles, deterministic eligibility followed by planner selection, a mandatory feature pipeline, and restricted feature or read-only analysis scripts. Every feature run receives a distinct exclusive worktree. Concurrency defaults to 8 under an immutable process-wide maximum of 32. Non-trivial UI work requires at least three structurally distinct prototypes and explicit user acceptance. Before implementation, the user chooses a fresh plan-seeded session, compaction of the current session that prefers `smart_compact` and falls back to normal Pi compaction when unavailable, or unchanged current context. Review uses a fresh session and excludes every Agent Profile that mutated the reviewed diff. + +This keeps the caller interface small while making approval, prototype, recovery, and reviewer-independence rules enforceable in one workflow engine. We rejected unrestricted custom graphs because they could bypass policy, a general registry architecture because it adds speculative extension points, and event-sourced persistence because revisioned snapshots already provide the required recovery and audit data with less machinery. + +## Consequences + +- Child agents inherit the existing nono sandbox; the restricted script process remains defense in depth for model-authored JavaScript. +- Project Agent Profiles and project configuration are loaded only for trusted projects. +- Potentially mutating interrupted Assignments require explicit recovery and are never replayed automatically. +- A versioned `plan.md` is the implementation handoff source for fresh-session and compacted-context transitions. Durable Gate-bound intents and callbacks keep implementation blocked until session replacement or compaction completes; normal Pi compaction is permitted only after the host confirms `smart_compact` is inactive, while an actual execution failure remains visible. +- Fresh-session replacement rotates the run lease to the new active parent session inside `withSession`; historical parent sessions may inspect but cannot mutate the run. +- The extension creates one exclusive worktree per feature run on `feature/`, where the validated feature name describes the feature without a numeric or run-ID suffix. Any existing branch rejects a new start and, when persisted state maps it to a Workflow Run, directs the caller to continue through `inspect` or `decide`; otherwise it directs the caller to choose another descriptive name. The extension does not merge, commit, reset, or remove the worktree automatically in the first version. +- `_workflows` remains inactive until migration or removal is approved separately. diff --git a/docs/pi-workflow/README.md b/docs/pi-workflow/README.md new file mode 100644 index 0000000..b230123 --- /dev/null +++ b/docs/pi-workflow/README.md @@ -0,0 +1,68 @@ +# pi-workflow + +`pi-workflow` plans and implements feature work through persisted human Gates, isolated Git worktrees, focused verification, and independent review. + +## Start a feature + +Ask the assistant to use `pi_workflow` with: + +- `action: "start"` +- `workflow: { mode: "feature" }` (or omit `workflow`) +- A descriptive lowercase kebab-case `featureName` +- The feature request +- A unique idempotency key + +The extension creates `feature/` in an exclusive worktree. Existing branch names are rejected rather than silently renamed. + +The managed flow is: + +1. Discover repository facts and ask unresolved Questions. +2. Design the implementation and explicitly approve the plan and UI-impact classification. +3. For non-trivial UI, review three browser-validated candidates and accept one revision or request refinement. +4. Choose a fresh plan-seeded session, compacted current session, or unchanged current context. +5. Implement with guarded file ownership. +6. Remove prototype-only behavior. +7. Run plan-approved verification commands and focused tests. +8. Complete independent specification and standards review. + +The assistant must inspect the current run before each state-changing call and obtain the user's explicit Gate Decision. It must not infer approval. + +## Inspect and continue + +Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information. + +Use: + +```text +/workflow continue +``` + +only when the dashboard or `pi_workflow` reports a pending fresh-session handoff or normal-compaction continuation. + +## Script modes + +A feature run may include one restricted read-only script at a closed insertion point: + +- `before-design` +- `after-design` +- `before-review` + +An analysis run uses `action: "start"` with `workflow: { mode: "analysis-script", source, args? }` and does not create a worktree or feature Gates. Scripts expose only `stage`, `assign`, `parallel`, and immutable `args`. Their Assignment tools are the intersection of the configured analysis allowlist, the selected Markdown profile, and active parent tools. + +## Recovery + +Interrupted work opens a Recovery Gate with retry, inspect, and abort choices. Completed Assignments and Decisions remain persisted. Potentially mutating interrupted Assignments are preserved and are not replayed automatically; an explicit retry creates newly fenced work where policy permits it. + +## Artifacts + +Run state and bounded artifacts are stored under: + +```text +~/.pi/agent/pi-workflows/runs// +``` + +Important files include immutable plan revisions, Assignment transcripts, retained prototype reports and screenshots, `result.json`, and the terminal Markdown report. Completed worktrees remain available for manual inspection and integration. + +## Reload after exposure + +Repository-managed extension links are controlled through `config/pi-links.json`. Run `/reload` after the managed link is synchronized so Pi discovers `pi-workflow`. diff --git a/docs/pi-workflow/dashboard-mocks.md b/docs/pi-workflow/dashboard-mocks.md new file mode 100644 index 0000000..a5142ef --- /dev/null +++ b/docs/pi-workflow/dashboard-mocks.md @@ -0,0 +1,141 @@ +# pi-workflow dashboard mocks + +These are static TUI direction mocks for `/workflow`. They are not implementation screenshots. + +## A. Split workspace + +Best for comparing Stage progress and Assignment detail without navigation. + +```text +pi-workflow 118 cols + +Runs feature/search-page · waiting +> search-page waiting plan-approval · revision 12 + auth-cleanup running Worktree .worktrees/pi-workflow-search-page-r1 + cache-audit done Branch feature/search-page + Plan artifacts/plan-11-a31f.md +Stages Report pending +✓ Discover +✓ Design Open Gate · Plan Approval +· Prototype UI impact: non-trivial +· Implement Review the plan and UI evidence before deciding. +· Verify +· Review [a] approve [r] revise [o] open plan + +↑↓ run tab pane enter action esc close +``` + +Narrow terminal: + +```text +pi-workflow · search-page +waiting · plan-approval · r12 + +✓ Discover ✓ Design · Prototype +· Implement · Verify · Review + +Plan Approval +UI impact: non-trivial +[o] plan [enter] actions +``` + +## B. Focused run cards + +Minimal chrome. The list stays compact; selecting a run opens one vertically ordered detail card. + +```text +pi-workflow + +> search-page WAITING Plan Approval r12 + auth-cleanup RUNNING Implement r31 + cache-audit DONE report available r18 + +──────────────────────────────────────────────────────────── +search-page +feature/search-page + +Progress Discover ✓ Design ✓ Prototype · Implement · + Verify · Review · + +Gate Plan Approval + non-trivial UI · 3 prototype candidates required + +Artifacts plan-11-a31f.md +Worktree .worktrees/pi-workflow-search-page-r1 + +[enter] actions [o] artifact [esc] close +``` + +Action sheet: + +```text +search-page · Plan Approval + +> Inspect plan + Record approval + Request revision + Copy worktree path + Close +``` + +Prototype Gate: + +```text +search-page · Prototype Acceptance + +1 Sidebar navigation /prototype?variant=1 + mobile ✓ desktop ✓ browser evidence retained +2 Command palette /prototype?variant=2 + mobile ✓ desktop ✓ browser evidence retained +3 Inline workspace /prototype?variant=3 + mobile ✓ desktop ✓ browser evidence retained + +> Open candidate 1 + Accept candidate 1 + Request refinement + Abort run +``` + +Narrow terminal uses the same vertical card and hides paths until `o`. + +## C. Event timeline + +Best for recovery and auditability. The primary view is an immutable history; actions belong to the current event. + +```text +pi-workflow · search-page WAITING + +12:04 ✓ Discover 2 assignments +12:09 ✓ Questions decision recorded +12:15 ✓ Design plan revision 11 +12:16 ● Plan Approval waiting for Decision + +Current +UI impact non-trivial +Plan plan-11-a31f.md +Worktree feature/search-page + +[enter] current actions [p] prototypes [r] report [esc] +``` + +Run list: + +```text +Recent workflow events + +> search-page Plan Approval 2m + auth-cleanup Implement 8m + cache-audit Completed 31m +``` + +Narrow terminals preserve the same timeline and omit timestamps first. + +## Direction criteria + +The implementation should: + +- Start dark-mode friendly through Pi theme tokens. +- Keep the default view compact and reveal paths, evidence, and transcripts on demand. +- Show only valid actions for the current persisted Gate or continuation intent. +- Keep run status, revision, branch, worktree, plan, report, and prototype evidence discoverable. +- Remain usable from narrow terminals through 4K-width terminals without continuous animation. diff --git a/docs/pi-workflow/design.md b/docs/pi-workflow/design.md new file mode 100644 index 0000000..de8b461 --- /dev/null +++ b/docs/pi-workflow/design.md @@ -0,0 +1,679 @@ +# pi-workflow Design + +**Status:** Accepted +**Date:** 24AUG2026 + +## Purpose + +`pi-workflow` coordinates feature planning and implementation through selected Markdown-defined agents, persisted human gates, isolated Pi child sessions, and mandatory independent review. + +The extension is based on `extensions/_workflows`, but it is a new extension rather than a rename. The existing extension remains an inactive reference until `pi-workflow` is verified and explicitly exposed. + +## Goals + +- Load bundled Agent Profiles and trusted project overrides from `.pi/agents/*.md`. +- Select agents automatically using deterministic eligibility followed by planner selection with recorded rationale. +- Pause after discovery for unresolved questions and after design for user approval. +- Require at least three prototype candidates and explicit user acceptance for non-trivial UI or UX work. +- Prevent an implementation agent from reviewing its own work. +- Support configurable concurrency with a user-controlled safety ceiling. +- Retain a restricted JavaScript DSL for custom feature and read-only analysis workflows. +- Run every feature in one dedicated Git worktree. +- Let the user choose a fresh plan-seeded session, smart compaction with normal-compaction fallback, or unchanged current context before implementation. +- Recover safely after process interruption without replaying potentially mutating work automatically. + +## Non-goals + +- Replacing the outer nono sandbox or granting capabilities beyond it. +- Allowing child agents to ask the user directly. +- Supporting arbitrary workflow graphs that can bypass required gates. +- Automatically merging a completed worktree into its source branch. +- Adding a general plugin framework for custom stages, policies, or persistence backends. +- Supporting global Agent Profiles in the first version. + +## Language + +**Agent Profile:** A versioned Markdown definition containing an agent's purpose, instructions, model preferences, tools, roles, and Eligibility Criteria. + +**Eligibility Criteria:** Machine-readable conditions that determine whether an Agent Profile may be considered for an Assignment. + +**Assignment:** One stage-specific task executed by one Agent Profile in a fresh child session. + +**Workflow Run:** The persisted execution of one feature or analysis request. + +**Stage:** A named unit of workflow progress controlled by the engine. + +**Gate:** A persisted point where the Workflow Run waits for a valid Decision. + +**Decision:** A revision-checked user or policy response that satisfies or rejects a Gate. + +**Prototype Candidate:** One throwaway design option produced for user comparison. + +**Review Assignment:** A fresh-session Assignment whose Agent Profile differs from every implementation Assignment it reviews. + +**Implementation Context Choice:** The user's persisted selection of a fresh plan-seeded session, compacted current session, or unchanged current session before implementation starts. The compacted strategy prefers `smart_compact` and falls back to normal Pi compaction only when that tool is unavailable. + +## External Interface + +The extension exposes one state-changing tool and one workflow command. + +```ts +type PiWorkflowInput = + | { + action: "start"; + request: string; + featureName?: string; + workflow?: + | { mode: "feature" } + | { + mode: "feature-script"; + insertionPoint: "before-design" | "after-design" | "before-review"; + source: string; + args?: string; + } + | { mode: "analysis-script"; source: string; args?: string }; + concurrency?: number; + background?: boolean; + idempotencyKey: string; + } + | { + action: "decide"; + runId: string; + gateId: string; + expectedRevision: number; + decision: GateDecision; + idempotencyKey: string; + } + | { action: "inspect"; runId: string } + | { + action: "abort"; + runId: string; + expectedRevision: number; + idempotencyKey: string; + }; +``` + +Gate Decisions use closed, gate-specific schemas: + +```ts +type GateDecision = + | { + kind: "answer-questions"; + answers: Record; + } + | { + kind: "approve-plan"; + approved: true; + acceptedUiImpact: "none" | "minor" | "non-trivial"; + comment?: string; + } + | { + kind: "revise-plan"; + feedback: string; + } + | { + kind: "accept-prototype"; + candidateId: string; + candidateRevision: number; + browserReviewed: true; + feedback?: string; + } + | { + kind: "refine-prototypes"; + feedback: string; + } + | { + kind: "select-implementation-context"; + strategy: "fresh-session" | "smart-compact" | "current-session"; + } + | { + kind: "report-context-preparation"; + preparationId: string; + planRevision: number; + method: "smart_compact"; + outcome: "unavailable" | "succeeded" | "no-op"; + } + | { + kind: "report-context-preparation"; + preparationId: string; + planRevision: number; + method: "smart_compact"; + outcome: "failed"; + error: string; + } + | { + kind: "recover"; + resolution: "retry" | "inspect" | "abort"; + }; +``` + +Feature and feature-script starts require `featureName` as a concise lowercase kebab-case name that describes what the feature does, such as `searchable-workflow-dashboard`. Analysis starts omit it. The host validates the name before persisting launch intent. + +The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow continue ` performs a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. + +When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `pi_workflow` with `action: "decide"`. Child agents never receive `ask_user`. + +Background runs return a run ID immediately. When they reach a Gate or settle, the extension sends one custom follow-up message with a stable delivery key to the active parent session. + +After `start` creates the run lease, every subsequent state-changing parent operation is authenticated against its active parent-session ID. Session IDs retained in `parentSessionIds` are historical discovery links and permit inspection only. + +## Agent Profiles + +### Discovery and precedence + +Profiles are loaded in this order: + +1. Bundled profiles from `extensions/pi-workflow/agents/*.md`. +2. Project profiles from the nearest `.pi/agents/*.md`, only when `ctx.isProjectTrusted()` is true. + +Files are read in lexical order. The first duplicate within one source wins and emits a diagnostic. A project profile replaces a bundled profile with the same normalized name. Provenance and a content digest are retained. Regular files and symlinks are accepted only when their resolved paths remain inside the selected profile root. + +Profile contents are pinned into the Workflow Run before selection. Editing a Markdown file does not change an active run. + +### Markdown schema + +```md +--- +name: frontend-prototyper +description: Produces structurally distinct browser-reviewable UI prototypes. +roles: [prototype] +model: anthropic/claude-opus-4-6 +effort: high +tools: [read, grep, find, ls, edit, write] +selection: + stages: [prototype] + anyTags: [frontend, ui, ux] + allTags: [] + pathGlobs: ["src/**/*.svelte", "src/**/*.tsx"] + keywords: [dashboard, page, component, responsive] +--- + +Agent instructions go here. +``` + +Required fields: + +- `name`: lowercase kebab case, unique after precedence is applied. +- `description`: concise purpose used by selection and the planner. +- `roles`: one or more of `discover`, `design`, `prototype`, `implement`, `verify`, or `review`. +- `selection`: structured Eligibility Criteria with at least one stage; additional filters may be empty for a deliberate fallback profile. +- Markdown body: non-empty agent instructions. + +Optional fields: + +- `model`: bare model ID or `provider/model-id`. +- `provider`: accepted only with a bare `model` field. +- `effort`: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. +- `tools`: CSV or YAML string array. + +Unknown fields produce diagnostics and are ignored. Malformed files are skipped independently so one profile cannot break the catalog. + +### Eligibility and planner selection + +The engine normalizes request and repository evidence into facts: + +```ts +interface WorkflowFacts { + stage: StageKind; + taskKind: "feature" | "analysis"; + requestTerms: string[]; + candidatePaths: string[]; + languages: string[]; + tags: string[]; + riskTags: string[]; +} +``` + +Eligibility is deterministic: + +- The profile must support the requested role and stage. +- Every `allTags` entry must be present. +- At least one `anyTags` entry must match when the list is non-empty. +- At least one `pathGlobs` entry must match when the list is non-empty. +- At least one normalized `keywords` entry must match when the list is non-empty. + +Pre-discovery selection uses only facts available without a model call: workflow mode, requested stage, normalized request terms, repository language markers, and paths named by the request. Criteria that require unavailable discovery facts do not match. A bundled general discovery profile provides an explicit fallback. + +The engine selects the initial discovery profile deterministically from eligible profiles using specificity and profile name as the stable tie-breaker. Empty and ambiguous selections fail clearly. After discovery, the selected planner receives only eligible profiles and chooses Assignments with a structured rationale. The host validates every choice and persists the eligible set, rejected profiles with reasons, selected profiles, and rationale. + +## Configuration + +Configuration sources are: + +1. Bundled defaults. +2. `~/.pi/agent/pi-workflow.json`. +3. Trusted `.pi/pi-workflow.json`. +4. Per-run invocation values. + +Invocation values may change requested concurrency but never the safety ceiling. A project may lower the global ceiling but cannot raise it. + +```json +{ + "concurrency": { + "default": 8, + "ceiling": 32 + }, + "maxAssignmentsPerRun": 128, + "maxReviewCycles": 3, + "analysisTools": ["read", "grep", "find", "ls"], + "worktrees": { + "root": ".worktrees" + } +} +``` + +`32` is an immutable process-wide maximum, not merely a default. Global and trusted project configuration may lower it but cannot raise it. The scheduler uses one extension-wide semaphore so concurrent Workflow Runs cannot exceed the effective process ceiling in aggregate. Each run also has its requested or configured limit. + +The first-version analysis and review tool sets are audited built-in allowlists. Configuration may remove tools but cannot add arbitrary extension tools by name. + +Effective per-run concurrency is: + +```text +min(requested or configured default, global ceiling, trusted project ceiling, 32) +``` + +Invalid, zero, non-integral, or out-of-range values fail before the run starts. + +## Workflow State + +```ts +type RunStatus = + | "running" + | "waiting" + | "completed" + | "failed" + | "aborted"; + +type AssignmentStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "interrupted"; + +interface WorkflowRun { + runId: string; + revision: number; + mode: "feature" | "analysis"; + status: RunStatus; + request: string; + featureName?: string; + parentSessionIds: string[]; + planArtifact?: PlanArtifact; + implementationContext?: ImplementationContextState; + lease?: RunLease; + workspace: WorkflowWorkspace; + effectiveConfig: EffectiveWorkflowConfig; + pinnedProfiles: PinnedAgentProfile[]; + facts: WorkflowFacts; + stages: Stage[]; + gates: Gate[]; + decisions: Decision[]; + assignments: Assignment[]; + prototypeCandidates: PrototypeCandidate[]; + deliveries: DeliveryRecord[]; + uiImpact?: UIImpact; + error?: WorkflowError; +} +``` + +Only the workflow engine may transition Stage, Gate, Assignment, context-preparation, or run status. Each accepted state-changing command increments `revision` and atomically replaces the persisted snapshot. Read-only inspection does not increment the revision. `ImplementationContextState` persists the Gate ID, plan revision, preparation ID, selected strategy, observed method/outcome, active parent-session ID, and any fresh-session or normal-compaction intent. + +## Built-in Feature Pipeline + +```text +Initialize + -> Discover + -> Questions Gate, when unresolved questions exist + -> Design + -> Plan Approval Gate + -> Prototype, only for non-trivial UI impact + -> Prototype Acceptance Gate + -> Implementation Context Gate + -> Implement + -> Verify + -> Review + -> Remediate -> Verify -> Review, when changes are requested + -> Complete +``` + +### 1. Initialize + +- Resolve effective configuration, validate the feature name, and validate the repository without mutation. +- Load, validate, and pin Agent Profiles from the source checkout. +- Verify that eligible discovery, design, implementation, verification, and independent review profiles exist before mutating the repository. +- For feature mode, verify that the configured worktree root is ignored by Git and that the source checkout is clean. +- Persist a run snapshot with workspace state `creating` before invoking Git. +- Create a distinct worktree transactionally, then persist workspace state `ready`. +- On creation failure, roll back what can be removed safely, persist the failure, and leave enough intent for startup reconciliation. +- Persist Assignment intent before starting every child. + +### 2. Discover + +Discovery Assignments inspect the repository and return structured facts, relevant files, risks, precedents, unresolved questions, and preliminary UI impact evidence. + +If unresolved questions exist, the engine persists a Questions Gate and waits. Answers become a Decision and are included in every later planning prompt. + +### 3. Design + +The planner selects eligible Agent Profiles with recorded rationale and produces: + +- Scope and acceptance criteria. +- Stage Assignments. +- Explicit file ownership for every mutating Assignment. +- Verification commands and expected outcomes. +- A structured UI impact classification: `none`, `minor`, or `non-trivial`, with affected surfaces and evidence. +- The independent Review Assignment target. + +The engine combines planner output with objective UI signals from request terms, affected paths, routes, layout/navigation changes, accessibility, responsiveness, and user-flow changes. Objective signals of non-trivial UI impact cannot be downgraded by the planner. The approved design is written atomically to a bounded, versioned `plan.md` containing requirements, Decisions, selected Assignments, file ownership, verification commands, worktree details, and UI impact evidence. The Plan Approval view shows the classification and evidence, and the Decision explicitly accepts that classification. Implementation and prototyping cannot begin before approval. + +### 4. Prototype + +This Stage is required when UI impact is `non-trivial`. + +- Produce at least three structurally distinct Prototype Candidates. +- Use separate fresh Assignments and candidate-specific paths. Profiles may repeat when only one prototyper is eligible. +- Prefer an existing host page and a `?variant=` switcher. +- Use realistic data and responsive layouts. +- Keep prototypes out of production behavior and mark them as throwaway. +- Return launch instructions, routes, candidate summaries, and artifact paths. + +The engine opens a Prototype Acceptance Gate only after at least three candidates have unique IDs, revisions, paths, routes, structural rationales, responsive checks, and browser-launch evidence. The user reviews them in the browser or application and may accept one successful candidate revision, request refinement, or abort. The accepted candidate and feedback produce a new `plan.md` revision before implementation-context selection. Accepted prototype code is evidence, not production code; implementation rewrites the accepted direction to production standards. A post-acceptance cleanup Assignment removes prototype-only routes, losing candidates, and switchers before verification while retaining bounded artifacts outside the production tree. + +### 5. Choose Implementation Context + +Immediately before implementation, the engine persists an Implementation Context Gate referencing the final approved `plan.md` revision. For work without non-trivial UI impact this follows Plan Approval; for non-trivial UI work it follows Prototype Acceptance and the resulting plan revision. + +The user chooses one strategy: + +- `fresh-session`: the engine atomically persists a handoff intent bound to the Gate ID, plan revision, run revision, source parent-session ID, and unique preparation ID before `/workflow continue ` calls `ExtensionCommandContext.newSession()`. During the expected `session_shutdown`, the matching source session and `reason: "new"` atomically claim the event's `targetSessionFile` onto that intent and enter `claimed-target-awaiting-withSession`. The target's immediate `session_start` with `reason: "new"` and matching `previousSessionFile` suppresses recovery for that startup lifecycle only. Only the following replacement `withSession` callback for the claimed target may consume the intent: it attaches the new active parent-session ID, seeds the bounded `plan.md`, run ID, revision, worktree path, and continuation instruction, and then satisfies the Gate. Cancellation or failure before consumption keeps the Gate waiting with the original session active when possible. +- `smart-compact`: the engine persists a preparation ID bound to the Gate and final plan revision. When `pi.getActiveTools()` contains `smart_compact`, the parent invokes it with `plan.md` as the focus and reports `succeeded`, `no-op`, or `failed` through a `report-context-preparation` Decision carrying that preparation ID and plan revision. When the parent reports `unavailable`, the host accepts it only if `smart_compact` is absent from the active tool inventory, persists a normal-compaction intent, and directs `/workflow continue ` to invoke `ctx.compact({ customInstructions, onComplete, onError })` with equivalent plan-focused instructions. The invocation's intent-bound `onComplete` or `onError` callback consumes that exact intent and records the terminal method and outcome. A successful or no-op preparation satisfies the Gate; a failure remains visible and waiting. An available-but-failed `smart_compact` attempt never silently falls back. +- `current-session`: the Gate is satisfied immediately and the current context is retained unchanged. + +Session replacement or compaction occurs only while the Workflow Run is waiting with no active child Assignments. Every callback, command, and event must match the active parent-session ID, Gate ID, preparation ID, and plan revision. A stale value, failed transition, or mismatched strategy leaves the Gate waiting and cannot start implementation. + +### 6. Implement + +Mutating Assignments run in the Workflow Run's worktree. + +- Every Assignment has declared file ownership. +- All mutating Assignments are serialized in the first version. Concurrency remains available for read-only Assignments. +- Mutating child sessions do not receive `bash` or other process-execution tools. Assignment-scoped file operations reject symlink traversal and require canonical containment beneath both the worktree root and the Assignment's owned path roots. +- Plan-approved verification and generation commands run separately through a host executor using fixed executable/argument arrays, never agent-authored shell strings. +- The engine records branch, HEAD, worktree identity, and Git diff state before and after every mutating Assignment and host command. +- Out-of-scope changes, branch movement, commits, or worktree metadata changes invalidate the Assignment and open a Recovery Gate before more mutation starts. +- The scheduler reserves capacity synchronously before launching children. +- Assignment intent is persisted before child execution. +- The extension does not commit, merge, reset, or remove work automatically in the first version. + +### 7. Verify + +Verification runs the focused tests, type checks, linting, builds, and browser validation required by the approved plan. Changed behavior must have focused tests. UI changes must be exercised in the application or browser with realistic data. + +A failed verification returns to remediation or opens a Recovery Gate when automatic remediation is unsafe. + +### 8. Review + +The engine maintains a cumulative set of every Agent Profile that mutated the reviewed diff, including implementation, prototype cleanup, and remediation. Review selection excludes that complete set. + +Each Review Assignment: + +- Uses a fresh child session. +- Receives only an audited read-only tool allowlist plus an internal read-only diff tool. +- Records a zero-diff postcondition; any repository change invalidates the verdict. +- Receives the approved plan, accepted prototype Decision when applicable, verification evidence, and complete diff scope. +- Reports specification conformance and repository standards as distinct structured axes. +- Returns a structured verdict and findings. + +`changes_requested` creates remediation Assignments whose profiles join the cumulative mutator set, then repeats Verify and Review in another fresh session. A reviewer profile may never remediate the work it reviews. If exclusions leave no eligible reviewer, the run fails closed before further mutation. After three unsuccessful review cycles by default, the engine opens a Recovery Gate for user direction. Completion requires an approved review verdict. + +## Custom Script Modes + +The existing restricted JavaScript runtime remains as defense in depth inside nono. + +### Feature script + +A feature script is bound by its closed `insertionPoint` input to run before Design, after Design, or before Review. It may request only custom read-only analysis Assignments at that point. Every pre-implementation custom Assignment uses the audited analysis tool allowlist. The host still injects and enforces Questions, Plan Approval, Prototype Acceptance, implementation verification, and independent review policy. Scripts cannot choose another insertion point at runtime, create mutating Assignments before approval, satisfy Gates, reorder mandatory Stages, or create child sessions directly. + +### Analysis script + +An analysis script may orchestrate read-only Assignments without feature Gates. Its effective tool set is the intersection of: + +- The configured `analysisTools` allowlist. +- The selected Agent Profile's tools. +- Tools active in the parent session. + +The fixed first-version allowlist excludes `bash`, mutation tools, recursive orchestration, user interaction, process execution, and unaudited extension tools. Configuration may only remove entries from that allowlist. + +### Script-visible interface + +```js +stage("Audit"); +const results = await parallel([ + () => assign("review", "Inspect persistence behavior"), + () => assign("review", "Inspect cancellation behavior"), +]); +return results; +``` + +`stage`, `assign`, `parallel`, and immutable `args` communicate through authenticated, bounded IPC. The script has no filesystem, network, process, import, timer, eval, or dynamic code-generation access. + +## Worktree Policy + +Every feature Workflow Run owns a distinct, exclusive worktree under: + +```text +/.worktrees/pi-workflow--/ +``` + +The corresponding branch is: + +```text +feature/ +``` + +`` comes from the validated `featureName` start input and describes the feature rather than the workflow mechanism. Any existing branch with that name rejects a new start. If persisted state associates the branch with a Workflow Run, the error returns its run ID and directs the caller to continue it through `inspect` or `decide`; otherwise it directs the caller to choose another descriptive name. The extension never adds a numeric or run-ID suffix silently. + +The configured worktree root must already be ignored by Git. The extension verifies this with Git before persisting creation intent and fails with setup guidance rather than modifying `.gitignore` automatically. A dirty source checkout blocks creation so uncommitted work is never silently omitted. + +The engine records an ownership lease for the run and refuses to adopt or share another run's worktree. The completed worktree remains available for inspection and manual integration. Removal is explicit and never part of normal completion. + +Read-only analysis runs use the parent cwd and do not create worktrees. All child sessions inherit the parent nono sandbox. The extension neither invokes nono nor widens filesystem or network permissions. + +## Persistence and Recovery + +Artifacts live under: + +```text +~/.pi/agent/pi-workflows// +``` + +Files include: + +```text +workflow.json +request.md +plan.md +config.json +profiles/ +handoffs/ +transcripts.json +result.json +artifacts/ +``` + +Writes are atomic and mode `0600`. Serialization, transcript, source, argument, result, and IPC bounds from `_workflows` are retained. + +The snapshot contains Decisions and prior Assignment outcomes, so a separate event store is unnecessary. Every `start`, `decide`, and `abort` command requires an idempotency key. The launch key and initial result are persisted before workspace creation. State-changing commands use revisions and idempotency keys to reject stale or duplicate transitions. Repeating a completed key returns its recorded result without another transition. + +An active run owns a persisted lease containing process identity, active parent-session identity, heartbeat time, and worktree identity. The run retains every parent session ID attached through a handoff so `/workflow` can discover it from either session, but only the active ID may mutate the run. Startup and session recovery reconcile stale leases, child processes, workspace-creation intent, Git identity, context handoffs, compaction intents, and assignments left `running`. + +Fresh-session handoff intent is durable before `newSession()`. A `session_shutdown` with reason `new` bypasses ordinary interruption recovery only when its source session matches the pending intent; that handler atomically claims the event's `targetSessionFile` and marks the intent `claimed-target-awaiting-withSession`. The claimed target's immediately following `session_start` is exempt from recovery only when its reason is `new` and `previousSessionFile` matches the source. The replacement `withSession` callback must then match the claimed target before it atomically consumes the intent, rotates the active parent-session ID, records both session IDs, and resumes delivery. Any later `session_start` caused by `startup`, `reload`, `resume`, or `fork`, or a bounded claim expiry before consumption, follows normal recovery and leaves implementation blocked. + +Every Gate and terminal background notification has a deterministic delivery key and persisted pending/sent state. Delivery uses a custom parent-session message containing that key in structured details. The parent session is the receiver-side receipt: recovery scans durable session entries for the key before retrying, then marks the outbox item sent. This closes the crash window between message persistence and sender-side state updates. + +On process or session interruption: + +- Active Assignments become `interrupted` idempotently. +- Potentially mutating Assignments are never replayed automatically. +- The run opens a Recovery Gate offering retry, inspect, or abort. +- Completed Assignments and accepted Decisions remain intact. + +## Child Execution + +Every Assignment creates a fresh in-memory Pi session using the parent model registry, Assignment cwd, resolved trust decision, settings, skills, extensions, and `AGENTS.md` context. Worktree trust is resolved with `resolveStandaloneChildProjectTrust`; an alternate cwd never inherits a session-only trust decision implicitly. + +The effective model is resolved in this order: + +1. Assignment override allowed by the workflow definition. +2. Agent Profile model/provider/effort. +3. Parent session model and thinking level. + +Unknown or ambiguous models fail the Assignment before execution. The effective provider, model, effort, profile digest, child session ID, and context capacity are persisted. + +Children cannot access recursive workflow or subagent tools or `ask_user`. `pi_workflow` is added to both the shared child-tool exclusion policy and the separate Pi-subagent child exclusion list alongside the legacy `workflow` name; implementation should consolidate the duplicate list onto the shared policy when possible. Each child tool call retains the shared timeout guard, including tools registered after startup. The first-response watchdog and bounded shutdown behavior from `_workflows` are retained. + +## Internal Modules + +`pi-workflow` should remain a small set of deep modules: + +- `index.ts`: Pi tool, command, lifecycle, and composition root. +- `engine.ts`: command handling, state transitions, gates, policies, and scheduling. +- `profiles.ts`: Markdown discovery, validation, eligibility, pinning, and diagnostics. +- `execution.ts`: fresh child sessions, assignment-scoped file operations, approved host commands, model resolution, structured output, transcripts, and timeouts. +- `script-runtime.ts` plus `sandbox-child.cjs`: restricted custom DSL and IPC. +- `store.ts`: atomic revisioned snapshots and bounded artifacts. +- `workspace.ts`: worktree creation, ownership conflict checks, and lifecycle. +- `context-transition.ts`: plan handoffs, new-session replacement, smart/default compaction callbacks, and session attachment. +- `dashboard.ts`: run inspection, continuation handoff, and prototype/report links. +- `agents/*.md`: bundled default profiles. + +Pure transition and eligibility logic stay inside the engine and profile modules rather than behind speculative adapter interfaces. Tests may inject filesystem, executor, decision, and script-runtime adapters at the composition root. + +## Exposure and Migration + +- Do not rename or activate `_workflows` during initial development. +- Add `extensions/pi-workflow` tests to `package.json` explicitly. +- Typecheck through the existing `extensions/**/*.ts` configuration. +- Add `pi-workflow` to `config/pi-links.json` only after focused tests, full tests, independent review, and manual TUI validation pass. +- Run the link reconciler in check mode before sync. +- Preserve unrelated managed links and require `/reload` after exposure. + +## Implementation Phases + +### Phase 1: Domain and profile catalog + +Implement the Workflow Run model, configuration resolution, Markdown profile loader, deterministic Eligibility Criteria, and bundled profiles. + +#### Phase 1 Success Criteria + +- Trusted project profiles override bundled profiles deterministically, while untrusted project profiles are never read. +- Profile files and symlinks resolving outside the selected bundled or trusted `.pi/agents` root are rejected. +- Malformed profiles produce isolated diagnostics and cannot suppress valid profiles. +- Profile contents, provenance, and effective model settings are pinned by digest. +- Every profile has stage Eligibility Criteria, pre-discovery fallback is deterministic, and irrelevant profiles are excluded with recorded reasons. +- Configuration resolves default concurrency 8 and an immutable process-wide maximum of 32; project and run settings cannot raise that maximum. + +### Phase 2: Engine, gates, and persistence + +Implement the command interface, legal transitions, closed Gate Decisions, atomic revisioned snapshots, idempotency, leases, delivery state, recovery, transactional worktree creation scaffold, and the built-in feature state machine through Plan Approval using an injected scripted Assignment executor for tests. + +#### Phase 2 Success Criteria + +- An exhaustive legal and illegal Stage/Gate transition matrix passes through `execute(command)`; invalid transitions fail closed. +- `start`, `decide`, and `abort` require idempotency keys; launch intent is persisted before worktree creation, and a repeated completed key returns its prior result without a second transition. +- Stale revisions are rejected for `decide` and `abort`. +- Using the injected scripted Assignment executor, Questions and Plan Approval pause and resume across process restarts with the accepted UI classification intact and no dependency on live Pi child execution. +- Startup reconciliation detects stale leases and transitions abandoned `running` Assignments to `interrupted` exactly once. +- Potentially mutating interrupted Assignments require an explicit Recovery Decision; completed Decisions and Assignments are not replayed. +- Feature names are validated before launch persistence; worktree branches use exactly `feature/`, and every existing branch collision rejects new start with recorded-run `inspect`/`decide` guidance or descriptive-name rename guidance, never a silent suffix. +- Workspace creation intent and lease are persisted before Git mutation; creation succeeds transactionally or leaves a reconciled failure that startup can recover. +- Interrupted atomic writes leave a complete prior or next snapshot, never partial JSON. + +### Phase 3: Child execution and selection + +Implement the production Pi Assignment executor and eligible planner-selection path, then integrate child sessions, structured output, model selection, process-wide scheduling, transcript capture, trust resolution, and timeout behavior with the Phase 2 engine. + +#### Phase 3 Success Criteria + +- Live Discover and Design Assignments drive the built-in pipeline through Questions and Plan Approval using the production executor. +- Every Assignment uses a fresh child session and resolves worktree trust through `resolveStandaloneChildProjectTrust`. +- Planner choices are restricted to eligible profiles and persist selected, rejected, and rationale data. +- Aggregate running Assignments across all Workflow Runs never exceed 32 or a lower configured ceiling. +- Capacity is reserved synchronously; queued cancellation and bounded session shutdown release capacity exactly once. +- `pi_workflow`, legacy workflow/subagent tools, and user interaction remain unavailable through both shared workflow-child and Pi-subagent child policies. +- A tool registered after child startup is still guarded by `extensions/shared/tool-call-timeout.ts`. + +### Phase 4: Worktrees, implementation, verification, and review + +Extend the Phase 2 worktree scaffold with the Implementation Context Gate, `/workflow continue`, and the production command/callback context-transition adapter, then add serialized mutation, canonical ownership and Git-state enforcement, verification evidence, independent review, and remediation cycles. + +#### Phase 4 Success Criteria + +- A dirty source state or unignored worktree root blocks creation before Git mutation. +- Workspace creation intent is persisted first; creation failure rolls back safely or remains reconcilable after restart. +- Every feature run receives a distinct leased worktree and worktrees are retained until explicitly removed. +- A versioned `plan.md` is persisted before the Implementation Context Gate and revised with an accepted prototype when applicable. +- Phase 4 registers `/workflow continue`; its `ExtensionCommandContext` performs both fresh-session replacement and approved normal-compaction fallback rather than deferring either path to dashboard integration. +- `fresh-session` persists a Gate-, revision-, plan-, preparation-, and source-session-bound handoff intent before `newSession`; only the replacement `withSession` callback consumes it, attaches the new active parent session, seeds the bounded plan handoff, and resumes. +- `smart-compact` uses a Gate-bound `report-context-preparation` transition with distinct `unavailable`, `succeeded`, `no-op`, and `failed` outcomes; `failed` requires a non-empty actionable error. The host validates `unavailable` against `pi.getActiveTools()`; only that outcome permits plan-focused `ctx.compact({ onComplete, onError })`, whose intent-bound callback persists success or failure. +- An available-but-failed `smart_compact` call does not silently fall back and keeps the Implementation Context Gate waiting with an actionable error; `current-session` preserves context unchanged. +- Context transitions run only while no child Assignment is active. Every state-changing parent call must match the lease's active parent-session ID; historical IDs are inspection-only, and stale Gate, preparation, plan, or run revisions keep implementation blocked. +- The pending fresh-session intent survives its matching old-session `reason: "new"` shutdown and atomically enters `claimed-target-awaiting-withSession`; only the target's immediate matching `reason: "new"` `session_start` suppresses recovery before `withSession` consumes it. Later startup, reload, resume, fork, or expiry recovers an unconsumed claim without starting implementation. +- Mutating Assignments are serialized, receive no process-execution tools, reject symlink traversal, and require canonical write containment beneath both the worktree and owned path roots. +- Plan-approved host commands use fixed executable/argument arrays; out-of-ownership writes or branch, HEAD, commit, reset, clean, or worktree changes fail before another mutation begins. +- Review excludes the cumulative set of profiles that mutated the diff, including remediation and prototype cleanup, and fails closed when no independent reviewer remains. +- Review uses a fresh session, an audited read-only tool set, and a zero-diff postcondition. +- Review reports specification conformance and repository standards separately. +- Completion is impossible without focused behavioral tests, successful verification, and approved independent review. + +### Phase 5: Prototype policy and custom scripts + +Add objective UI-impact validation, three-candidate prototype handling, post-acceptance cleanup, Prototype Acceptance, and restricted feature/analysis script modes. + +#### Phase 5 Success Criteria + +- Objective signals of non-trivial UI impact cannot be downgraded, and Plan Approval explicitly accepts the displayed classification. +- Non-trivial UI work cannot reach implementation without three successful, structurally distinct candidate revisions and explicit acceptance of one. +- Prototype candidates use realistic host context, are browser-reviewable and responsive, and produce launch and validation evidence. +- Prototype-only routes, losing variants, and switchers are removed or isolated before verification while bounded artifacts remain inspectable. +- Analysis and pre-implementation feature scripts receive only the audited read-only tool allowlist. +- Feature scripts are bound to a validated closed insertion point and cannot reorder or bypass mandatory Gates, prototype policy, verification, or review. +- Script IPC tests reject malformed, duplicate, oversized, unauthenticated, and over-budget requests and prove cancellation terminates active work. + +### Phase 6: Dashboard, integration, and exposure + +Add the dashboard integration around the Phase 4 `/workflow continue` command, persisted notification outbox, reports, complete verification, documentation, and managed exposure. + +#### Phase 6 Success Criteria + +- Dashboard state is derived from persisted snapshots and active runs, prior supported snapshot versions remain readable, and the existing `/workflow continue` command is presented only for a valid pending fresh-session or normal-compaction intent. +- Stable delivery keys, durable custom parent-session message receipts, and outbox reconciliation ensure each background Gate or terminal result is delivered once across reload and recovery. +- Focused tests, typecheck, full test suite, and manual Pi TUI validation of Gates, dashboard navigation, background delivery, and prototype handoff pass. +- Exposure is performed only through `config/pi-links.json`; unmanaged extension links are rejected and link reconciliation check passes before sync. +- `_workflows` remains available as an inactive reference until removal is separately approved. + +## Verification Notes + +- Test profile trust, precedence, malformed YAML isolation, and containment of regular files and symlinks. +- Test the exhaustive legal and illegal Stage/Gate transition matrix through the engine interface. +- Test stale revision, duplicate idempotency, lease reconciliation, and notification outbox handling. +- Test process-wide semaphore reservation races, queued cancellation, bounded session shutdown, and interrupted recovery. +- Test all three Implementation Context choices; Gate-bound `smart_compact` unavailable/succeeded/no-op/failed reports; required actionable failure errors; active-tool validation; intent-bound normal-compaction `onComplete`/`onError` callbacks; available-tool failure without fallback; and stale Gate, preparation, plan, run, and active-session identities. +- Test fresh-session intent persistence before `newSession`, exact shutdown claim, immediate target `session_start` exemption, `withSession` consumption, cancelled replacement, active-session rotation, old-session mutation rejection, and recovery of unconsumed claims on later startup/reload/resume/fork or expiry, plus the no-active-child transition precondition. +- Test cumulative reviewer profile/session exclusion, read-only postconditions, and failure when no independent reviewer exists. +- Test non-trivial UI classification cannot bypass structural candidate evidence, user acceptance, or prototype cleanup. +- Test analysis and pre-approval feature modes cannot activate mutation, recursive orchestration, interaction, process, or unaudited extension tools. +- Test malformed, duplicate, oversized, unauthenticated, and over-budget script IPC plus cancellation escalation. +- Test atomic persistence and backward-tolerant dashboard reads. +- Manually validate Gate dialogs, background delivery, dashboard navigation, browser prototype handoff, and responsive candidates in Pi TUI and the application. + +## Precedents & Lessons + +- `_workflows` already proves bounded sandbox IPC, atomic artifacts, child-session execution, transcript capture, and dashboard rendering. +- `extensions/shared/child-session.ts` is the source of truth for project trust inheritance, recursive-tool exclusion, and bounded child teardown. +- `extensions/shared/tool-call-timeout.ts` must continue guarding tools registered after child startup. +- `extensions/subagents` demonstrates synchronous capacity reservation, normalized lifecycle state, and duplicate-delivery hazards. +- `skills/design/prototype/UI.md` requires structurally different variants, realistic host context, browser review, and throwaway cleanup. +- `skills/develop/code-review/SKILL.md` establishes independent standards and specification review as separate concerns. +- `config/pi-links.json` is the only approved exposure mechanism for repository-managed global extensions. diff --git a/extensions/_workflows/runner.ts b/extensions/_workflows/runner.ts index 63b1bf3..973fac6 100644 --- a/extensions/_workflows/runner.ts +++ b/extensions/_workflows/runner.ts @@ -12,10 +12,10 @@ import { createAgentSession, - DefaultResourceLoader, + type DefaultResourceLoader, defineTool, SessionManager, - SettingsManager, + type SettingsManager, type AgentSession, type AgentSessionEvent, type AgentSessionEventListener, @@ -29,6 +29,7 @@ import { childToolPolicy, createChildResources, shutdownAndDisposeChildSession, + waitForChildOperation, } from "../shared/child-session.ts"; import { createToolCallTimeoutGuard } from "../shared/tool-call-timeout.ts"; import { emptyUsage, type AgentUsage, type TranscriptEntry } from "./model.ts"; @@ -68,7 +69,10 @@ export interface AgentOutcome { error?: string; aborted: boolean; usage: AgentUsage; + provider?: string; model?: string; + thinkingLevel?: ThinkingLevel; + childSessionId?: string; contextWindow?: number; transcript: TranscriptEntry[]; } @@ -91,7 +95,15 @@ export interface RunAgentOptions { settingsManager: SettingsManager; modelRegistry: ExtensionContext["modelRegistry"]; signal?: AbortSignal; + tools?: string[]; + customTools?: ToolDefinition[]; onProgress?: (progress: AgentProgress) => void; + onSessionCreated?: (metadata: { + childSessionId: string; + provider?: string; + model?: string; + thinkingLevel: ThinkingLevel; + }) => void; /** Test-only override for the per-tool execution timeout. */ toolCallTimeoutMs?: number; /** Test-only override for the first assistant response-event timeout. */ @@ -430,18 +442,24 @@ export async function runAgent( options: RunAgentOptions, ): Promise { let structured: unknown; - let customTools: ToolDefinition[] | undefined; + let customTools: ToolDefinition[]; let session: AgentSession | undefined; let unsubscribeToolTimeout: (() => void) | undefined; try { - customTools = - options.schema !== undefined - ? [ + customTools = [ + ...(options.customTools ?? []), + ...(options.schema === undefined + ? [] + : [ makeStructuredOutputTool(options.schema, (value) => { structured = value; }), - ] - : undefined; + ]), + ]; + const customToolNames = customTools.map((tool) => tool.name); + if (new Set(customToolNames).size !== customToolNames.length) { + throw new Error("Workflow child custom tool names must be unique"); + } ({ session } = await createAgentSession({ cwd: options.cwd, ...(options.model ? { model: options.model } : {}), @@ -451,7 +469,8 @@ export async function runAgent( resourceLoader: options.loader, settingsManager: options.settingsManager, sessionManager: SessionManager.inMemory(options.cwd), - ...(customTools ? { customTools } : {}), + ...(customTools.length > 0 ? { customTools } : {}), + ...(options.tools ? { tools: options.tools } : {}), ...childToolPolicy(), })); await bindChildSessionExtensions(session); @@ -468,15 +487,30 @@ export async function runAgent( error: `Failed to create agent session: ${errorText(error)}`, aborted: false, usage: emptyUsage(), + provider: options.model?.provider, model: options.model?.id, + thinkingLevel: options.thinkingLevel, contextWindow: options.model?.contextWindow, transcript: [], }; } const childSession = session; + const childSessionId = childSession.sessionManager.getSessionId(); + const thinkingLevel = childSession.thinkingLevel; let usage = emptyUsage(); + let provider = childSession.model?.provider ?? options.model?.provider; let modelId = childSession.model?.id ?? options.model?.id; + try { + options.onSessionCreated?.({ + childSessionId, + provider, + model: modelId, + thinkingLevel, + }); + } catch { + // Metadata observers must not disrupt or leak a successfully created child. + } let contextWindow = childSession.model?.contextWindow; let stopReason: string | undefined; let errorMessage: string | undefined; @@ -487,6 +521,7 @@ export async function runAgent( usage = computeUsage(messages); const sessionModel = childSession.model; + provider = sessionModel?.provider ?? provider; modelId = sessionModel?.id ?? modelId; contextWindow = sessionModel?.contextWindow ?? contextWindow; const context = childSession.getContextUsage(); @@ -515,6 +550,7 @@ export async function runAgent( !sessionModel || (msg.provider === sessionModel.provider && msg.model === sessionModel.id); + provider = msg.provider; const reportedId = msg.responseModel ?? msg.model; const reportedModel = responseMatchesSession ? options.modelRegistry.find(msg.provider, reportedId) @@ -555,9 +591,14 @@ export async function runAgent( let aborted = false; let abortPromise: Promise | undefined; + let resolveCancellation = () => {}; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); const onAbort = () => { aborted = true; - abortPromise ??= childSession.abort().catch(() => {}); + resolveCancellation(); + abortPromise ??= waitForChildOperation(childSession.abort()); }; if (options.signal) { if (options.signal.aborted) onAbort(); @@ -573,9 +614,12 @@ export async function runAgent( model: modelId, }); markFirstResponse = watchdog.markResponse; - await watchdog.waitFor( - childSession.prompt(buildWorkflowAgentPrompt(options.prompt)), - ); + await Promise.race([ + watchdog.waitFor( + childSession.prompt(buildWorkflowAgentPrompt(options.prompt)), + ), + cancellation, + ]); } } catch (error) { errorMessage = errorMessage ?? errorText(error); @@ -602,7 +646,10 @@ export async function runAgent( error: "Agent was aborted", aborted: true, usage, + provider, model: modelId, + thinkingLevel, + childSessionId, contextWindow, transcript, }; @@ -617,7 +664,10 @@ export async function runAgent( error: errorMessage ?? "Agent failed", aborted: false, usage, + provider, model: modelId, + thinkingLevel, + childSessionId, contextWindow, transcript, }; @@ -631,7 +681,10 @@ export async function runAgent( "Agent finished without calling structured_output; no structured result matching the schema was produced.", aborted: false, usage, + provider, model: modelId, + thinkingLevel, + childSessionId, contextWindow, transcript, }; @@ -643,7 +696,10 @@ export async function runAgent( structured, aborted: false, usage, + provider, model: modelId, + thinkingLevel, + childSessionId, contextWindow, transcript, }; diff --git a/extensions/pi-workflow/agents/feature-designer.md b/extensions/pi-workflow/agents/feature-designer.md new file mode 100644 index 0000000..4591684 --- /dev/null +++ b/extensions/pi-workflow/agents/feature-designer.md @@ -0,0 +1,16 @@ +--- +name: feature-designer +description: Turns discovery evidence and user decisions into an actionable implementation plan. +roles: [design] +tools: [read, grep, find, ls] +selection: + stages: [design] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: [] +--- + +# Feature designer + +Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible. The plan is complete when an implementer can execute it without inventing missing contracts or crossing another assignment's ownership. diff --git a/extensions/pi-workflow/agents/feature-implementer.md b/extensions/pi-workflow/agents/feature-implementer.md new file mode 100644 index 0000000..35313b2 --- /dev/null +++ b/extensions/pi-workflow/agents/feature-implementer.md @@ -0,0 +1,16 @@ +--- +name: feature-implementer +description: Implements one approved assignment within declared file ownership. +roles: [implement] +tools: [read, grep, find, ls, edit, write] +selection: + stages: [implement] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: [] +--- + +# Feature implementer + +Read the approved plan and implement only the assigned behavior within the declared owned paths. Follow existing project patterns, keep the change focused, and add tests for changed behavior. Report modified files, assumptions, and commands the host should run. The assignment is complete when the owned change satisfies its acceptance criteria without modifying files or Git state outside the assignment contract. diff --git a/extensions/pi-workflow/agents/feature-verifier.md b/extensions/pi-workflow/agents/feature-verifier.md new file mode 100644 index 0000000..788574f --- /dev/null +++ b/extensions/pi-workflow/agents/feature-verifier.md @@ -0,0 +1,16 @@ +--- +name: feature-verifier +description: Verifies approved behavior with focused tests, static checks, builds, and required browser evidence. +roles: [verify] +tools: [read, grep, find, ls] +selection: + stages: [verify] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: [] +--- + +# Feature verifier + +Use `workflow_plan` repeatedly until every character range in the approved plan has been read. Compare that plan with the implementation evidence and inspect every required result from the host-provided command path. Confirm focused behavioral tests, type checks, linting, builds, and browser validation when applicable. Report each check and its actual outcome. Verification is complete only when every acceptance criterion has passing evidence or a precise failure that routes back to remediation. diff --git a/extensions/pi-workflow/agents/general-discovery.md b/extensions/pi-workflow/agents/general-discovery.md new file mode 100644 index 0000000..9e52006 --- /dev/null +++ b/extensions/pi-workflow/agents/general-discovery.md @@ -0,0 +1,16 @@ +--- +name: general-discovery +description: Maps the repository, relevant behavior, constraints, risks, and unresolved questions before planning. +roles: [discover] +tools: [read, grep, find, ls] +selection: + stages: [discover] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: [] +--- + +# General discovery + +Inspect the request and repository before proposing a design. Return relevant files and symbols, current behavior, integration points, constraints, risks, precedents, objective UI-impact evidence, and questions that cannot be answered from the repository. Distinguish verified facts from inferences. Discovery is complete when every requested behavior has a concrete codebase anchor or an explicit unresolved question. diff --git a/extensions/pi-workflow/agents/independent-reviewer.md b/extensions/pi-workflow/agents/independent-reviewer.md new file mode 100644 index 0000000..f9876e1 --- /dev/null +++ b/extensions/pi-workflow/agents/independent-reviewer.md @@ -0,0 +1,16 @@ +--- +name: independent-reviewer +description: Reviews the completed diff independently for specification conformance and repository standards. +roles: [review] +tools: [read, grep, find, ls] +selection: + stages: [review] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: [] +--- + +# Independent reviewer + +Review the complete diff, approved plan, decisions, prototype evidence when applicable, and verification results without modifying the repository. Use `workflow_plan` and `workflow_diff` repeatedly until every character range reported by each tool has been read. Assess specification conformance and repository standards as separate axes. Report only concrete findings with locations, evidence, severity, and remediation. Review is complete when every changed behavior and owned file has been checked and the verdict is explicitly approved or changes requested. diff --git a/extensions/pi-workflow/agents/ui-prototyper.md b/extensions/pi-workflow/agents/ui-prototyper.md new file mode 100644 index 0000000..4ef91f4 --- /dev/null +++ b/extensions/pi-workflow/agents/ui-prototyper.md @@ -0,0 +1,16 @@ +--- +name: ui-prototyper +description: Produces structurally distinct browser-reviewable prototypes for non-trivial UI work. +roles: [prototype] +tools: [read, grep, find, ls, edit, write] +selection: + stages: [prototype] + anyTags: [frontend, ui, ux] + allTags: [] + pathGlobs: [] + keywords: [] +--- + +# UI prototyper + +Create the assigned throwaway prototype in the existing application context with realistic data and responsive behavior. Make its structure meaningfully different from the other candidates, document its rationale, and provide a route and launch instructions for browser review. Keep prototype-only behavior isolated and easy to remove. The candidate is complete when its unique path, route, rationale, and responsive checks are recorded. Use the browser to validate the exact candidate route at mobile (at most 480px wide) and desktop (at least 1024px wide) viewports. Save each screenshot inside the candidate-owned path and write a JSON report beside it with `{ "route": , "result": "passed", "viewport": { "width": , "height": }, "screenshotPath": }`. Return the repository-relative JSON report paths in `browserEvidence`; self-reported prose is not evidence. diff --git a/extensions/pi-workflow/config.test.ts b/extensions/pi-workflow/config.test.ts new file mode 100644 index 0000000..2992b22 --- /dev/null +++ b/extensions/pi-workflow/config.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { loadWorkflowConfig } from "./config.ts"; +import { DEFAULT_WORKFLOW_CONFIG, MAX_CONCURRENCY } from "./domain.ts"; + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-config-")); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +async function writeJson(file: string, value: unknown) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(value)}\n`); +} + +test("configuration defaults to concurrency 8 with immutable ceiling 32", async () => { + await withTempDir(async (directory) => { + const config = await loadWorkflowConfig({ + cwd: directory, + agentDir: path.join(directory, "agent"), + projectTrusted: false, + }); + assert.deepEqual(config, DEFAULT_WORKFLOW_CONFIG); + assert.equal(config.concurrency.ceiling, MAX_CONCURRENCY); + }); +}); + +test("a lowered ceiling clamps an inherited default", async () => { + await withTempDir(async (directory) => { + const agentDir = path.join(directory, "agent"); + await writeJson(path.join(agentDir, "pi-workflow.json"), { + concurrency: { ceiling: 4 }, + }); + const globalOnly = await loadWorkflowConfig({ + cwd: directory, + agentDir, + projectTrusted: false, + }); + assert.deepEqual(globalOnly.concurrency, { default: 4, ceiling: 4 }); + + await writeJson(path.join(agentDir, "pi-workflow.json"), { + concurrency: { default: 6, ceiling: 16 }, + }); + await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + concurrency: { ceiling: 3 }, + }); + const project = await loadWorkflowConfig({ + cwd: directory, + agentDir, + projectTrusted: true, + }); + assert.deepEqual(project.concurrency, { default: 3, ceiling: 3 }); + }); +}); + +test("trusted project configuration layers over global configuration and may lower limits", async () => { + await withTempDir(async (directory) => { + const agentDir = path.join(directory, "agent"); + await writeJson(path.join(agentDir, "pi-workflow.json"), { + concurrency: { default: 6, ceiling: 16 }, + maxAssignmentsPerRun: 64, + analysisTools: ["read", "grep", "find"], + worktrees: { root: ".worktrees/global" }, + }); + await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + concurrency: { default: 4, ceiling: 8 }, + maxReviewCycles: 5, + analysisTools: ["read", "find", "ls"], + worktrees: { root: ".worktrees/project" }, + }); + + const config = await loadWorkflowConfig({ + cwd: directory, + agentDir, + projectTrusted: true, + invocationConcurrency: 7, + }); + assert.deepEqual(config.concurrency, { default: 7, ceiling: 8 }); + assert.equal(config.maxAssignmentsPerRun, 64); + assert.equal(config.maxReviewCycles, 5); + assert.deepEqual(config.analysisTools, ["read", "find"]); + assert.equal(config.worktrees.root, path.normalize(".worktrees/project")); + }); +}); + +test("untrusted project configuration is never applied", async () => { + await withTempDir(async (directory) => { + const agentDir = path.join(directory, "agent"); + await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + concurrency: { default: 1, ceiling: 1 }, + unknownField: true, + }); + const config = await loadWorkflowConfig({ + cwd: directory, + agentDir, + projectTrusted: false, + }); + assert.deepEqual(config.concurrency, { default: 8, ceiling: 32 }); + }); +}); + +test("configuration cannot raise ceilings or activate unaudited tools", async () => { + await withTempDir(async (directory) => { + const agentDir = path.join(directory, "agent"); + await writeJson(path.join(agentDir, "pi-workflow.json"), { + concurrency: { ceiling: 33 }, + }); + await assert.rejects( + loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }), + /between 1 and 32/, + ); + + await writeJson(path.join(agentDir, "pi-workflow.json"), { + analysisTools: ["read", "bash"], + }); + await assert.rejects( + loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }), + /unsupported tools: bash/, + ); + }); +}); + +test("project configuration cannot raise a lowered global ceiling", async () => { + await withTempDir(async (directory) => { + const agentDir = path.join(directory, "agent"); + await writeJson(path.join(agentDir, "pi-workflow.json"), { + concurrency: { default: 4, ceiling: 8 }, + }); + await writeJson(path.join(directory, ".pi", "pi-workflow.json"), { + concurrency: { ceiling: 16 }, + }); + await assert.rejects( + loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: true }), + /cannot raise the current ceiling/, + ); + }); +}); + +test("invalid numeric and path values fail before a run starts", async () => { + await withTempDir(async (directory) => { + const agentDir = path.join(directory, "agent"); + await writeJson(path.join(agentDir, "pi-workflow.json"), { + maxAssignmentsPerRun: 1.5, + }); + await assert.rejects( + loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }), + /must be an integer/, + ); + + await writeJson(path.join(agentDir, "pi-workflow.json"), { + worktrees: { root: "../outside" }, + }); + await assert.rejects( + loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }), + /must stay inside the repository/, + ); + + await writeJson(path.join(agentDir, "pi-workflow.json"), { + concurrency: { default: 8, ceiling: 8 }, + }); + await assert.rejects( + loadWorkflowConfig({ + cwd: directory, + agentDir, + projectTrusted: false, + invocationConcurrency: 9, + }), + /between 1 and 8/, + ); + }); +}); diff --git a/extensions/pi-workflow/config.ts b/extensions/pi-workflow/config.ts new file mode 100644 index 0000000..d6a9ee8 --- /dev/null +++ b/extensions/pi-workflow/config.ts @@ -0,0 +1,256 @@ +import { readFile } from "node:fs/promises"; +import * as path from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { + ANALYSIS_TOOL_ALLOWLIST, + DEFAULT_WORKFLOW_CONFIG, + MAX_ASSIGNMENTS_PER_RUN, + MAX_CONCURRENCY, + MAX_REVIEW_CYCLES, + type WorkflowConfig, +} from "./domain.ts"; + +const TOP_LEVEL_KEYS = new Set([ + "concurrency", + "maxAssignmentsPerRun", + "maxReviewCycles", + "analysisTools", + "worktrees", +]); +const CONCURRENCY_KEYS = new Set(["default", "ceiling"]); +const WORKTREE_KEYS = new Set(["root"]); + +export interface LoadWorkflowConfigOptions { + cwd: string; + projectTrusted: boolean; + agentDir?: string; + invocationConcurrency?: number; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertKnownKeys( + value: Record, + allowed: ReadonlySet, + label: string, +) { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new Error( + `${label}: unknown field${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}`, + ); + } +} + +function integerInRange( + value: unknown, + label: string, + minimum: number, + maximum: number, +) { + if (!Number.isSafeInteger(value) || typeof value !== "number") { + throw new Error(`${label} must be an integer`); + } + if (value < minimum || value > maximum) { + throw new Error(`${label} must be between ${minimum} and ${maximum}`); + } + return value; +} + +function stringArray(value: unknown, label: string) { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string") + ) { + throw new Error(`${label} must be an array of strings`); + } + const normalized = value.map((entry) => entry.trim()); + if (normalized.some((entry) => entry.length === 0)) { + throw new Error(`${label} cannot contain empty values`); + } + if (new Set(normalized).size !== normalized.length) { + throw new Error(`${label} cannot contain duplicate values`); + } + return normalized; +} + +function worktreeRoot(value: unknown, label: string) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${label} must be a non-empty relative path`); + } + const normalized = path.normalize(value.trim()); + if ( + path.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) + ) { + throw new Error(`${label} must stay inside the repository`); + } + return normalized; +} + +function cloneConfig(config: WorkflowConfig): WorkflowConfig { + return { + concurrency: { ...config.concurrency }, + maxAssignmentsPerRun: config.maxAssignmentsPerRun, + maxReviewCycles: config.maxReviewCycles, + analysisTools: [...config.analysisTools], + worktrees: { ...config.worktrees }, + }; +} + +function applyConfigLayer( + current: WorkflowConfig, + value: unknown, + label: string, +): WorkflowConfig { + if (!isRecord(value)) { + throw new Error(`${label} must contain a JSON object`); + } + assertKnownKeys(value, TOP_LEVEL_KEYS, label); + const next = cloneConfig(current); + + if (value.concurrency !== undefined) { + if (!isRecord(value.concurrency)) { + throw new Error(`${label}.concurrency must be an object`); + } + assertKnownKeys( + value.concurrency, + CONCURRENCY_KEYS, + `${label}.concurrency`, + ); + if (value.concurrency.ceiling !== undefined) { + const ceiling = integerInRange( + value.concurrency.ceiling, + `${label}.concurrency.ceiling`, + 1, + MAX_CONCURRENCY, + ); + if (ceiling > current.concurrency.ceiling) { + throw new Error( + `${label}.concurrency.ceiling cannot raise the current ceiling`, + ); + } + next.concurrency.ceiling = ceiling; + if (value.concurrency.default === undefined) { + next.concurrency.default = Math.min( + current.concurrency.default, + ceiling, + ); + } + } + if (value.concurrency.default !== undefined) { + next.concurrency.default = integerInRange( + value.concurrency.default, + `${label}.concurrency.default`, + 1, + next.concurrency.ceiling, + ); + } + if (next.concurrency.default > next.concurrency.ceiling) { + throw new Error( + `${label}.concurrency.default exceeds the effective ceiling`, + ); + } + } + + if (value.maxAssignmentsPerRun !== undefined) { + next.maxAssignmentsPerRun = integerInRange( + value.maxAssignmentsPerRun, + `${label}.maxAssignmentsPerRun`, + 1, + MAX_ASSIGNMENTS_PER_RUN, + ); + } + + if (value.maxReviewCycles !== undefined) { + next.maxReviewCycles = integerInRange( + value.maxReviewCycles, + `${label}.maxReviewCycles`, + 1, + MAX_REVIEW_CYCLES, + ); + } + + if (value.analysisTools !== undefined) { + const requested = stringArray( + value.analysisTools, + `${label}.analysisTools`, + ); + const invalid = requested.filter( + (tool) => !ANALYSIS_TOOL_ALLOWLIST.includes(tool as never), + ); + if (invalid.length > 0) { + throw new Error( + `${label}.analysisTools contains unsupported tools: ${invalid.join(", ")}`, + ); + } + next.analysisTools = current.analysisTools.filter((tool) => + requested.includes(tool), + ); + } + + if (value.worktrees !== undefined) { + if (!isRecord(value.worktrees)) { + throw new Error(`${label}.worktrees must be an object`); + } + assertKnownKeys(value.worktrees, WORKTREE_KEYS, `${label}.worktrees`); + if (value.worktrees.root !== undefined) { + next.worktrees.root = worktreeRoot( + value.worktrees.root, + `${label}.worktrees.root`, + ); + } + } + + return next; +} + +async function readOptionalJson(file: string) { + let contents: string; + try { + contents = await readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new Error(`${file}: unable to read configuration`, { cause: error }); + } + try { + return JSON.parse(contents) as unknown; + } catch (error) { + throw new Error(`${file}: invalid JSON`, { cause: error }); + } +} + +export async function loadWorkflowConfig( + options: LoadWorkflowConfigOptions, +): Promise { + const agentDir = options.agentDir ?? getAgentDir(); + let config = cloneConfig(DEFAULT_WORKFLOW_CONFIG); + + const globalFile = path.join(agentDir, "pi-workflow.json"); + const globalValue = await readOptionalJson(globalFile); + if (globalValue !== undefined) { + config = applyConfigLayer(config, globalValue, globalFile); + } + + if (options.projectTrusted) { + const projectFile = path.join(options.cwd, ".pi", "pi-workflow.json"); + const projectValue = await readOptionalJson(projectFile); + if (projectValue !== undefined) { + config = applyConfigLayer(config, projectValue, projectFile); + } + } + + if (options.invocationConcurrency !== undefined) { + config.concurrency.default = integerInRange( + options.invocationConcurrency, + "invocation concurrency", + 1, + config.concurrency.ceiling, + ); + } + + return config; +} diff --git a/extensions/pi-workflow/context-transition.test.ts b/extensions/pi-workflow/context-transition.test.ts new file mode 100644 index 0000000..ce3eb32 --- /dev/null +++ b/extensions/pi-workflow/context-transition.test.ts @@ -0,0 +1,509 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { ImplementationContextState, WorkflowRun } from "./domain.ts"; +import { + registerWorkflowContextTransitions, + type ContextTransitionRuntime, +} from "./context-transition.ts"; +import type { WorkflowCommand, WorkflowEngine, WorkflowView } from "./engine.ts"; + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-context-")); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +type CommandHandler = ( + args: string, + ctx: ExtensionCommandContext, +) => Promise; +type ShutdownHandler = ( + event: { reason: string; targetSessionFile?: string }, + ctx: ExtensionContext, +) => Promise; +type StartHandler = ( + event: { reason: string; previousSessionFile?: string }, + ctx: ExtensionContext, +) => Promise; + +function fakeApi() { + let command: CommandHandler | undefined; + let shutdown: ShutdownHandler | undefined; + let start: StartHandler | undefined; + const api = { + registerCommand(_name: string, options: { handler: CommandHandler }) { + command = options.handler; + }, + on(event: string, handler: ShutdownHandler | StartHandler) { + if (event === "session_shutdown") shutdown = handler as ShutdownHandler; + if (event === "session_start") start = handler as StartHandler; + }, + } as unknown as ExtensionAPI; + return { + api, + command: () => { + assert.ok(command); + return command; + }, + shutdown: () => { + assert.ok(shutdown); + return shutdown; + }, + start: () => { + assert.ok(start); + return start; + }, + }; +} + +function workflowView( + planPath: string, + revision: number, + context: ImplementationContextState, +): WorkflowView { + return { + runId: "run-1", + revision, + status: context.status === "ready" ? "running" : "waiting", + background: false, + workspace: { + state: "ready", + sourceRoot: "/repo", + root: "/repo/.worktrees/run-1", + branch: "feature/example", + baseHead: "abc123", + }, + openGate: + context.status === "ready" + ? undefined + : { + id: context.gateId, + kind: "implementation-context", + status: "open", + revision: 10, + openedAt: 1, + }, + planArtifact: { + path: planPath, + revision: 1, + digest: createHash("sha256").update(readFileSync(planPath)).digest("hex"), + }, + implementationContext: structuredClone(context), + stages: [], + assignments: [], + mutatorProfileNames: [], + reviewCycles: 0, + verificationCycles: 0, + uiImpactEvidence: [], + affectedUiSurfaces: [], + }; +} + +function commandContext(options: { + sessionId: string; + sessionFile: string; + compact?: ExtensionCommandContext["compact"]; + newSession?: ExtensionCommandContext["newSession"]; + sent?: unknown[]; +}) { + const notifications: string[] = []; + const context = { + cwd: "/repo", + sessionManager: { + getSessionId: () => options.sessionId, + getSessionFile: () => options.sessionFile, + }, + ui: { + notify(message: string) { + notifications.push(message); + }, + }, + async waitForIdle() {}, + compact: options.compact ?? (() => {}), + newSession: + options.newSession ?? + (async () => ({ cancelled: false })) as ExtensionCommandContext["newSession"], + } as unknown as ExtensionCommandContext; + return { context, notifications }; +} + +test("/workflow routes non-continuation arguments to the dashboard", async () => { + const registered = fakeApi(); + const opened: string[] = []; + registerWorkflowContextTransitions( + registered.api, + { + store: { + async listRuns() { + return []; + }, + }, + engineForContext: () => { + throw new Error("dashboard should not create an engine"); + }, + } as unknown as ContextTransitionRuntime, + async (args) => { + opened.push(args); + return {}; + }, + ); + await registered.command()("run-1", commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + }).context); + assert.deepEqual(opened, ["run-1"]); +}); + +test("/workflow continue invokes and fences normal compaction callbacks", async () => { + await withTempDir(async (directory) => { + const planPath = path.join(directory, "plan.md"); + await writeFile(planPath, "# Approved plan\n\nImplement safely.", "utf8"); + const context: ImplementationContextState = { + gateId: "gate-1", + runRevision: 10, + planRevision: 1, + preparationId: "prep-1", + activeParentSessionId: "session-1", + sourceParentSessionId: "session-1", + strategy: "smart-compact", + status: "normal-compact-pending", + method: "smart_compact", + outcome: "unavailable", + }; + let view = workflowView(planPath, 10, context); + const commands: WorkflowCommand[] = []; + const engine = { + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + if (command.action === "inspect") return structuredClone(view); + if (command.action === "continue-context") { + const next = structuredClone(context); + next.status = "normal-compact-running"; + next.method = "normal-compact"; + next.runRevision = 11; + view = workflowView(planPath, 11, next); + return structuredClone(view); + } + if (command.action === "complete-normal-compaction") { + if (command.expectedRevision !== view.revision) { + throw new Error("stale compaction callback"); + } + const next = structuredClone(view.implementationContext!); + next.status = command.outcome === "failed" ? "failed" : "ready"; + next.outcome = command.outcome; + view = workflowView(planPath, 12, next); + return structuredClone(view); + } + throw new Error(`Unexpected command ${command.action}`); + }, + } as WorkflowEngine; + const runtime = { + store: { listRuns: async () => [] }, + engineForContext: () => engine, + } as unknown as ContextTransitionRuntime; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, runtime); + let compactOptions: Parameters[0]; + const { context: ctx, notifications } = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + compact(options) { + compactOptions = options; + }, + }); + await registered.command()("continue run-1", ctx); + assert.match(compactOptions?.customInstructions ?? "", /Approved plan/); + compactOptions?.onComplete?.({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 100, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + commands.map((command) => command.action), + ["inspect", "continue-context", "complete-normal-compaction"], + ); + const completed = commands.at(-1); + assert.equal( + completed?.action === "complete-normal-compaction" + ? completed.outcome + : undefined, + "succeeded", + ); + compactOptions?.onComplete?.({ + summary: "duplicate", + firstKeptEntryId: "entry-1", + tokensBefore: 100, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.ok(notifications.some((message) => message.includes("stale compaction callback"))); + }); +}); + +test("cancelled fresh-session replacement clears the source-bound intent", async () => { + await withTempDir(async (directory) => { + const planPath = path.join(directory, "plan.md"); + await writeFile(planPath, "# Plan", "utf8"); + const context: ImplementationContextState = { + gateId: "gate-1", + runRevision: 10, + planRevision: 1, + preparationId: "prep-1", + activeParentSessionId: "session-1", + sourceParentSessionId: "session-1", + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }; + let view = workflowView(planPath, 10, context); + const commands: WorkflowCommand[] = []; + const engine = { + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + if (command.action === "inspect") return structuredClone(view); + if (command.action === "continue-context") { + const next = structuredClone(view.implementationContext!); + next.sourceSessionFile = command.sourceSessionFile; + next.runRevision = 11; + view = workflowView(planPath, 11, next); + return structuredClone(view); + } + if (command.action === "recover-context-intent") { + const next = structuredClone(view.implementationContext!); + next.sourceSessionFile = undefined; + next.intentCreatedAt = undefined; + next.intentExpiresAt = undefined; + next.runRevision = 12; + view = workflowView(planPath, 12, next); + return structuredClone(view); + } + throw new Error(`Unexpected command ${command.action}`); + }, + } as WorkflowEngine; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, { + store: { listRuns: async () => [] }, + engineForContext: () => engine, + } as unknown as ContextTransitionRuntime); + const command = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + newSession: (async () => ({ cancelled: true })) as ExtensionCommandContext["newSession"], + }); + await registered.command()("continue run-1", command.context); + assert.deepEqual( + commands.map((entry) => entry.action), + ["inspect", "continue-context", "recover-context-intent"], + ); + assert.equal(view.implementationContext?.sourceSessionFile, undefined); + }); +}); + +test("a later startup recovers an unconsumed claimed handoff", async () => { + await withTempDir(async (directory) => { + const planPath = path.join(directory, "plan.md"); + await writeFile(planPath, "# Plan", "utf8"); + const context: ImplementationContextState = { + gateId: "gate-1", + runRevision: 30, + planRevision: 1, + preparationId: "prep-1", + activeParentSessionId: "session-1", + sourceParentSessionId: "session-1", + sourceSessionFile: "/sessions/source.jsonl", + targetSessionFile: "/sessions/target.jsonl", + strategy: "fresh-session", + status: "claimed-target-awaiting-withSession", + method: "fresh-session", + intentExpiresAt: Date.now() + 60_000, + }; + let view = workflowView(planPath, 30, context); + const commands: WorkflowCommand[] = []; + const engine = { + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + if (command.action === "recover-context-intent") { + const next = structuredClone(view.implementationContext!); + next.status = "handoff-pending"; + next.targetSessionFile = undefined; + next.runRevision = 31; + view = workflowView(planPath, 31, next); + return structuredClone(view); + } + throw new Error(`Unexpected command ${command.action}`); + }, + } as WorkflowEngine; + const runtime = { + store: { + async listRuns() { + return [ + { + runId: view.runId, + revision: view.revision, + status: view.status, + implementationContext: structuredClone(view.implementationContext), + } as WorkflowRun, + ]; + }, + }, + engineForContext: () => engine, + } as unknown as ContextTransitionRuntime; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, runtime); + const target = commandContext({ + sessionId: "session-2", + sessionFile: "/sessions/target.jsonl", + }).context; + for (const scenario of [ + { eventReason: "startup", expectedReason: "startup", expired: false }, + { eventReason: "reload", expectedReason: "reload", expired: false }, + { eventReason: "resume", expectedReason: "resume", expired: false }, + { eventReason: "fork", expectedReason: "fork", expired: false }, + { eventReason: "startup", expectedReason: "expired", expired: true }, + ] as const) { + const pending = structuredClone(context); + pending.intentExpiresAt = scenario.expired + ? Date.now() - 1 + : Date.now() + 60_000; + view = workflowView(planPath, 30, pending); + commands.length = 0; + await registered.start()({ reason: scenario.eventReason }, target); + const recovered = commands[0]; + assert.equal(recovered?.action, "recover-context-intent"); + assert.equal( + recovered?.action === "recover-context-intent" + ? recovered.reason + : undefined, + scenario.expectedReason, + ); + assert.equal(view.implementationContext?.status, "handoff-pending"); + } + }); +}); + +test("fresh continuation claims shutdown target and consumes only in withSession", async () => { + await withTempDir(async (directory) => { + const planPath = path.join(directory, "plan.md"); + const planPrefix = "# Handoff plan\n"; + const boundaryPlan = `${planPrefix}${"a".repeat( + 32 * 1024 - 1 - Buffer.byteLength(planPrefix, "utf8"), + )}😀tail`; + await writeFile(planPath, boundaryPlan, "utf8"); + const context: ImplementationContextState = { + gateId: "gate-1", + runRevision: 20, + planRevision: 1, + preparationId: "prep-1", + activeParentSessionId: "session-1", + sourceParentSessionId: "session-1", + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }; + let view = workflowView(planPath, 20, context); + const commands: WorkflowCommand[] = []; + const sent: unknown[] = []; + const engine = { + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + if (command.action === "inspect") return structuredClone(view); + if (command.action === "continue-context") { + const next = structuredClone(view.implementationContext!); + next.sourceSessionFile = command.sourceSessionFile; + next.runRevision = 21; + view = workflowView(planPath, 21, next); + return structuredClone(view); + } + if (command.action === "claim-context-handoff") { + const next = structuredClone(view.implementationContext!); + next.status = "claimed-target-awaiting-withSession"; + next.targetSessionFile = command.targetSessionFile; + next.runRevision = 22; + view = workflowView(planPath, 22, next); + return structuredClone(view); + } + if (command.action === "consume-context-handoff") { + const next = structuredClone(view.implementationContext!); + next.status = "ready"; + next.activeParentSessionId = "session-2"; + next.runRevision = 23; + view = workflowView(planPath, 23, next); + return structuredClone(view); + } + throw new Error(`Unexpected command ${command.action}`); + }, + } as WorkflowEngine; + const runtime = { + store: { + async listRuns() { + return [ + { + runId: view.runId, + revision: view.revision, + status: view.status, + implementationContext: structuredClone(view.implementationContext), + } as WorkflowRun, + ]; + }, + }, + engineForContext: () => engine, + } as unknown as ContextTransitionRuntime; + const registered = fakeApi(); + registerWorkflowContextTransitions(registered.api, runtime); + const replacement = commandContext({ + sessionId: "session-2", + sessionFile: "/sessions/target.jsonl", + }).context as unknown as ExtensionCommandContext & { + sendMessage(message: unknown): Promise; + sendUserMessage(message: string): Promise; + }; + replacement.sendMessage = async (message) => { + sent.push(message); + }; + replacement.sendUserMessage = async () => {}; + const old = commandContext({ + sessionId: "session-1", + sessionFile: "/sessions/source.jsonl", + newSession: (async (options) => { + await registered.shutdown()( + { reason: "new", targetSessionFile: "/sessions/target.jsonl" }, + old.context, + ); + await registered.start()( + { reason: "new", previousSessionFile: "/sessions/source.jsonl" }, + replacement, + ); + await options?.withSession?.(replacement); + return { cancelled: false }; + }) as ExtensionCommandContext["newSession"], + }); + await registered.command()("continue run-1", old.context); + assert.equal(sent.length, 1); + const handoff = JSON.stringify(sent[0]); + assert.match(handoff, /Handoff plan/); + assert.doesNotMatch(handoff, /�|😀/); + assert.deepEqual( + commands.map((command) => command.action), + [ + "inspect", + "continue-context", + "claim-context-handoff", + "inspect", + "consume-context-handoff", + ], + ); + }); +}); diff --git a/extensions/pi-workflow/context-transition.ts b/extensions/pi-workflow/context-transition.ts new file mode 100644 index 0000000..2122320 --- /dev/null +++ b/extensions/pi-workflow/context-transition.ts @@ -0,0 +1,399 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { WorkflowRun } from "./domain.ts"; +import { + type CompleteNormalCompactionCommand, + type ConsumeContextHandoffCommand, + type ContinueContextCommand, + type DecideWorkflowCommand, + type WorkflowCommand, + type WorkflowEngine, + type WorkflowView, +} from "./engine.ts"; +import { + readVerifiedPlanArtifact, + type WorkflowStore, +} from "./store.ts"; + +const MAX_HANDOFF_BYTES = 32 * 1024; +type NewSessionOptions = NonNullable< + Parameters[0] +>; +type ReplacementContext = Parameters< + NonNullable +>[0]; + +export interface ContextTransitionRuntime { + store: WorkflowStore; + engineForContext(ctx: ExtensionContext): Promise | WorkflowEngine; +} + +function sessionId(ctx: ExtensionContext) { + const id = ctx.sessionManager.getSessionId(); + if (!id) throw new Error("Workflow context transition requires a persisted session ID"); + return id; +} + +function sessionFile(ctx: ExtensionContext) { + const file = ctx.sessionManager.getSessionFile(); + if (!file) throw new Error("Workflow context transition requires a persisted session file"); + return file; +} + +function contextCommand(view: WorkflowView) { + const context = view.implementationContext; + if (!context) throw new Error("Workflow Run has no Implementation Context intent"); + return { + runId: view.runId, + gateId: context.gateId, + preparationId: context.preparationId, + planRevision: context.planRevision, + contextRevision: context.runRevision, + expectedRevision: view.revision, + }; +} + +async function boundedPlan(view: WorkflowView) { + if (!view.planArtifact) throw new Error("Workflow Run has no persisted plan"); + const plan = await readVerifiedPlanArtifact(view.planArtifact); + let bytes = 0; + const characters: string[] = []; + for (const character of plan) { + const width = Buffer.byteLength(character, "utf8"); + if (bytes + width > MAX_HANDOFF_BYTES) break; + characters.push(character); + bytes += width; + } + return characters.join(""); +} + +function handoffMessage(view: WorkflowView, plan: string) { + return [ + `Continue pi-workflow run ${view.runId}.`, + `Worktree: ${view.workspace.root ?? "missing"}`, + `Plan revision: ${view.planArtifact?.revision ?? "missing"}`, + "The workflow state remains authoritative. Inspect it before taking action.", + "", + plan, + ].join("\n"); +} + +async function inspect( + runtime: ContextTransitionRuntime, + ctx: ExtensionContext, + runId: string, +) { + const engine = await runtime.engineForContext(ctx); + return { + engine, + view: await engine.execute({ action: "inspect", runId }), + }; +} + +async function completeNormalCompaction( + runtime: ContextTransitionRuntime, + ctx: ExtensionContext, + view: WorkflowView, + outcome: CompleteNormalCompactionCommand["outcome"], + error?: string, +) { + const engine = await runtime.engineForContext(ctx); + const command: CompleteNormalCompactionCommand = { + action: "complete-normal-compaction", + ...contextCommand(view), + outcome, + ...(error ? { error } : {}), + }; + await engine.execute(command); +} + +async function consumeHandoff( + runtime: ContextTransitionRuntime, + replacementCtx: ReplacementContext, + runId: string, + sourceSessionFile: string, +) { + const current = await inspect(runtime, replacementCtx, runId); + const targetSessionFile = sessionFile(replacementCtx); + const plan = await boundedPlan(current.view); + await replacementCtx.sendMessage({ + customType: "pi-workflow-handoff", + content: handoffMessage(current.view, plan), + display: true, + }); + const command: ConsumeContextHandoffCommand = { + action: "consume-context-handoff", + ...contextCommand(current.view), + targetSessionFile, + previousSessionFile: sourceSessionFile, + }; + await current.engine.execute(command); + replacementCtx.ui.notify(`Workflow ${runId} context handoff is ready`, "info"); +} + +async function continueFreshSession( + runtime: ContextTransitionRuntime, + ctx: ExtensionCommandContext, + view: WorkflowView, +) { + const currentSessionId = sessionId(ctx); + const sourceSessionFile = sessionFile(ctx); + if (view.implementationContext?.activeParentSessionId !== currentSessionId) { + throw new Error("Only the active parent session may continue this Workflow Run"); + } + const engine = await runtime.engineForContext(ctx); + const command: ContinueContextCommand = { + action: "continue-context", + ...contextCommand(view), + sourceSessionFile, + }; + const pending = await engine.execute(command); + const result = await ctx.newSession({ + parentSession: sourceSessionFile, + withSession: async (replacementCtx) => { + await consumeHandoff(runtime, replacementCtx, view.runId, sourceSessionFile); + }, + }); + if (result.cancelled) { + const pendingContext = pending.implementationContext; + if (pendingContext) { + await engine.execute({ + action: "recover-context-intent", + runId: pending.runId, + gateId: pendingContext.gateId, + preparationId: pendingContext.preparationId, + planRevision: pendingContext.planRevision, + contextRevision: pendingContext.runRevision, + expectedRevision: pending.revision, + reason: "cancelled", + }); + } + ctx.ui.notify("Workflow context handoff was cancelled", "warning"); + } +} + +async function continueNormalCompaction( + runtime: ContextTransitionRuntime, + ctx: ExtensionCommandContext, + view: WorkflowView, +) { + const plan = await boundedPlan(view); + const engine = await runtime.engineForContext(ctx); + const command: ContinueContextCommand = { + action: "continue-context", + ...contextCommand(view), + }; + const compacting = await engine.execute(command); + const instructions = [ + `Prepare context for pi-workflow run ${view.runId}.`, + `Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`, + "Use this approved plan as the implementation focus:", + plan, + ].join("\n\n"); + try { + ctx.compact({ + customInstructions: instructions, + onComplete: () => { + void completeNormalCompaction(runtime, ctx, compacting, "succeeded").catch( + (error: unknown) => { + ctx.ui.notify( + `Workflow compaction callback failed: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + }, + ); + }, + onError: (error) => { + void completeNormalCompaction( + runtime, + ctx, + compacting, + "failed", + error.message || "Normal compaction failed", + ).catch((callbackError: unknown) => { + ctx.ui.notify( + `Workflow compaction callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, + "error", + ); + }); + }, + }); + } catch (error) { + await completeNormalCompaction( + runtime, + ctx, + compacting, + "failed", + error instanceof Error ? error.message : String(error), + ); + throw error; + } +} + +async function handleContinue( + runtime: ContextTransitionRuntime, + args: string, + ctx: ExtensionCommandContext, +) { + const match = args.trim().match(/^continue\s+(\S+)$/); + if (!match) { + ctx.ui.notify("Usage: /workflow continue ", "error"); + return; + } + await ctx.waitForIdle(); + const runId = match[1]; + if (!runId) throw new Error("Workflow Run ID is missing"); + const { view } = await inspect(runtime, ctx, runId); + const status = view.implementationContext?.status; + if (status === "handoff-pending") { + await continueFreshSession(runtime, ctx, view); + return; + } + if (status === "normal-compact-pending") { + await continueNormalCompaction(runtime, ctx, view); + return; + } + throw new Error(`Workflow Run ${runId} has no pending context continuation`); +} + +export function executeHostDecision( + pi: Pick, + engine: WorkflowEngine, + command: DecideWorkflowCommand, +) { + return engine.execute({ + ...command, + ...(command.decision.kind === "report-context-preparation" + ? { activeToolNames: pi.getActiveTools() } + : {}), + }); +} + +export function registerWorkflowContextTransitions( + pi: ExtensionAPI, + runtime: ContextTransitionRuntime, + openDashboard?: ( + args: string, + ctx: ExtensionCommandContext, + ) => Promise<{ continueRunId?: string }>, +) { + pi.registerCommand("workflow", { + description: "Inspect and continue persisted pi-workflow runs", + handler: async (args, ctx) => { + try { + if (/^continue\s+\S+$/.test(args.trim())) { + await handleContinue(runtime, args, ctx); + return; + } + if (openDashboard) { + const result = await openDashboard(args.trim(), ctx); + if (result.continueRunId) { + await handleContinue( + runtime, + `continue ${result.continueRunId}`, + ctx, + ); + } + return; + } + await handleContinue(runtime, args, ctx); + } catch (error) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + } + }, + }); + + pi.on("session_start", async (event, ctx) => { + const currentSessionFile = ctx.sessionManager.getSessionFile(); + if (!currentSessionFile) return; + for (const run of await runtime.store.listRuns()) { + const context = run.implementationContext; + if (run.status !== "waiting" || context?.status !== "claimed-target-awaiting-withSession") { + continue; + } + const immediateTarget = + event.reason === "new" && + event.previousSessionFile === context.sourceSessionFile && + currentSessionFile === context.targetSessionFile && + (context.intentExpiresAt === undefined || context.intentExpiresAt >= Date.now()); + if (immediateTarget) continue; + const engine = await runtime.engineForContext(ctx); + try { + await engine.execute({ + action: "recover-context-intent", + runId: run.runId, + gateId: context.gateId, + preparationId: context.preparationId, + planRevision: context.planRevision, + contextRevision: context.runRevision, + expectedRevision: run.revision, + reason: + context.intentExpiresAt !== undefined && context.intentExpiresAt < Date.now() + ? "expired" + : event.reason === "reload" || + event.reason === "resume" || + event.reason === "fork" + ? event.reason + : "startup", + }); + } catch (error) { + if ( + !(error instanceof Error) || + !/owned by live process/.test(error.message) + ) { + throw error; + } + } + } + }); + + pi.on("session_shutdown", async (event, ctx) => { + if (event.reason !== "new" || !event.targetSessionFile) return; + const sourceSessionFile = ctx.sessionManager.getSessionFile(); + const sourceSessionId = ctx.sessionManager.getSessionId(); + if (!sourceSessionFile || !sourceSessionId) return; + for (const run of await runtime.store.listRuns()) { + const context = run.implementationContext; + if ( + run.status !== "waiting" || + context?.status !== "handoff-pending" || + context.sourceParentSessionId !== sourceSessionId || + context.sourceSessionFile !== sourceSessionFile + ) { + continue; + } + const engine = await runtime.engineForContext(ctx); + await engine.execute({ + action: "claim-context-handoff", + runId: run.runId, + gateId: context.gateId, + preparationId: context.preparationId, + planRevision: context.planRevision, + contextRevision: context.runRevision, + expectedRevision: run.revision, + sourceSessionFile, + targetSessionFile: event.targetSessionFile, + reason: "new", + }); + } + }); +} + +export type ContextTransitionCommand = Extract< + WorkflowCommand, + | { action: "continue-context" } + | { action: "claim-context-handoff" } + | { action: "consume-context-handoff" } + | { action: "complete-normal-compaction" } + | { action: "recover-context-intent" } +>; + +export function contextTransitionState(run: WorkflowRun) { + return run.implementationContext; +} diff --git a/extensions/pi-workflow/continuation.test.ts b/extensions/pi-workflow/continuation.test.ts new file mode 100644 index 0000000..016ed54 --- /dev/null +++ b/extensions/pi-workflow/continuation.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isValidContextContinuation } from "./continuation.ts"; + +function state(overrides: Record = {}) { + return { + revision: 5, + status: "waiting", + planArtifact: { revision: 2 }, + openGate: { + id: "gate-1", + kind: "implementation-context", + status: "open", + }, + implementationContext: { + gateId: "gate-1", + runRevision: 4, + planRevision: 2, + activeParentSessionId: "session-1", + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }, + assignments: [], + ...overrides, + }; +} + +test("continuation requires every persisted binding and the active parent", () => { + assert.equal(isValidContextContinuation(state(), "session-1"), true); + assert.equal(isValidContextContinuation(state(), "session-2"), false); + assert.equal(isValidContextContinuation(state({ status: "running" }), "session-1"), false); + assert.equal( + isValidContextContinuation( + state({ + assignments: [ + { + status: "queued", + executionToken: "active-token", + }, + ], + }), + "session-1", + ), + false, + ); + assert.equal( + isValidContextContinuation( + state({ + revision: 6, + }), + "session-1", + ), + false, + ); + assert.equal( + isValidContextContinuation( + state({ + implementationContext: { + ...state().implementationContext, + strategy: "smart-compact", + }, + }), + "session-1", + ), + false, + ); +}); + +test("normal compaction continuation requires unavailable smart compaction state", () => { + const context = { + ...state().implementationContext, + strategy: "smart-compact", + status: "normal-compact-pending", + method: "smart_compact", + outcome: "unavailable", + }; + assert.equal( + isValidContextContinuation( + state({ implementationContext: context }), + "session-1", + ), + true, + ); + assert.equal( + isValidContextContinuation( + state({ + implementationContext: { ...context, outcome: "failed" }, + }), + "session-1", + ), + false, + ); +}); diff --git a/extensions/pi-workflow/continuation.ts b/extensions/pi-workflow/continuation.ts new file mode 100644 index 0000000..cd0e27b --- /dev/null +++ b/extensions/pi-workflow/continuation.ts @@ -0,0 +1,65 @@ +interface ContinuationAssignment { + status: string; + executionToken?: string; +} + +interface ContinuationGate { + id: string; + kind: string; + status: string; +} + +interface ContinuationContext { + gateId: string; + runRevision: number; + planRevision: number; + activeParentSessionId: string; + strategy?: string; + status: string; + method?: string; + outcome?: string; +} + +export interface ContinuationState { + revision: number; + status: string; + planArtifact?: { revision: number }; + implementationContext?: ContinuationContext; + openGate?: ContinuationGate; + assignments: ContinuationAssignment[]; +} + +export function isValidContextContinuation( + run: ContinuationState, + sessionId?: string, +) { + const context = run.implementationContext; + const gate = run.openGate; + if ( + run.status !== "waiting" || + !context || + gate?.status !== "open" || + gate.kind !== "implementation-context" || + gate.id !== context.gateId || + run.planArtifact?.revision !== context.planRevision || + context.runRevision > run.revision || + run.revision - context.runRevision > 1 || + (sessionId && context.activeParentSessionId !== sessionId) || + run.assignments.some( + (assignment) => + assignment.status === "running" || + (assignment.status === "queued" && Boolean(assignment.executionToken)), + ) + ) { + return false; + } + return ( + (context.status === "handoff-pending" && + context.strategy === "fresh-session" && + context.method === "fresh-session") || + (context.status === "normal-compact-pending" && + context.strategy === "smart-compact" && + context.method === "smart_compact" && + context.outcome === "unavailable") + ); +} diff --git a/extensions/pi-workflow/dashboard.test.ts b/extensions/pi-workflow/dashboard.test.ts new file mode 100644 index 0000000..f308190 --- /dev/null +++ b/extensions/pi-workflow/dashboard.test.ts @@ -0,0 +1,384 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { + DEFAULT_WORKFLOW_CONFIG, + type WorkflowRun, +} from "./domain.ts"; +import type { WorkflowCommand, WorkflowView } from "./engine.ts"; +import { + FocusedWorkflowDashboard, + openWorkflowDashboard, +} from "./dashboard.ts"; + +function run(overrides: Partial = {}): WorkflowRun { + return { + runId: "run-1", + revision: 12, + mode: "feature", + status: "waiting", + background: false, + request: "Build search page", + featureName: "search-page", + parentSessionIds: ["session-1"], + lease: { + processId: 100, + activeParentSessionId: "session-1", + heartbeatAt: 1, + }, + workspace: { + state: "ready", + sourceRoot: "/repo", + root: "/repo/.worktrees/pi-workflow-search-page-run-1", + branch: "feature/search-page", + baseHead: "abc123", + }, + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + pinnedProfiles: [], + preflightEligibility: [], + facts: { + stage: "design", + taskKind: "feature", + requestTerms: ["search"], + candidatePaths: [], + languages: [], + tags: [], + riskTags: [], + }, + stages: [ + { id: "discover", kind: "discover", status: "completed" }, + { id: "design", kind: "design", status: "waiting" }, + { id: "prototype", kind: "prototype", status: "pending" }, + { id: "implement", kind: "implement", status: "pending" }, + { id: "verify", kind: "verify", status: "pending" }, + { id: "review", kind: "review", status: "pending" }, + ], + gates: [ + { + id: "gate-1", + kind: "plan-approval", + status: "open", + revision: 12, + openedAt: 1, + }, + ], + decisions: [], + assignments: [], + prototypeCandidates: [], + prototypeCleanupCompleted: false, + deliveries: [], + commandReceipts: [], + uiImpact: "minor", + uiImpactEvidence: ["One visible field changes"], + affectedUiSurfaces: ["Search page"], + mutatorProfileNames: [], + reviewCycles: 0, + verificationCycles: 0, + ...overrides, + }; +} + +const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +function view(workflow: WorkflowRun): WorkflowView { + return { + runId: workflow.runId, + revision: workflow.revision, + status: workflow.status, + background: workflow.background, + workspace: workflow.workspace, + openGate: workflow.gates.find((gate) => gate.status === "open"), + uiImpact: workflow.uiImpact, + uiImpactEvidence: workflow.uiImpactEvidence, + affectedUiSurfaces: workflow.affectedUiSurfaces, + planArtifact: workflow.planArtifact, + reportArtifact: workflow.reportArtifact, + implementationContext: workflow.implementationContext, + stages: workflow.stages, + assignments: workflow.assignments, + mutatorProfileNames: workflow.mutatorProfileNames, + reviewCycles: workflow.reviewCycles, + verificationCycles: workflow.verificationCycles, + }; +} + +test("focused dashboard renders compact and narrow cards within width", () => { + let selected: string | null | undefined; + const dashboard = new FocusedWorkflowDashboard( + [run()], + theme, + (value) => { + selected = value; + }, + ); + dashboard.handleInput("\r"); + const wide = dashboard.render(100); + assert.ok(wide.some((line) => line.includes("Progress"))); + assert.ok(wide.some((line) => line.includes("Worktree"))); + assert.ok(wide.every((line) => visibleWidth(line) <= 100)); + const narrow = dashboard.render(48); + assert.equal(narrow.some((line) => line.includes("Worktree")), false); + assert.ok(narrow.every((line) => visibleWidth(line) <= 48)); + dashboard.handleInput("\r"); + assert.equal(selected, "run-1"); +}); + +test("dashboard windows long-lived session run lists", () => { + const runs = Array.from({ length: 30 }, (_, index) => + run({ runId: `run-${index}`, featureName: `feature-${index}` }), + ); + const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {}); + const lines = dashboard.render(100); + assert.ok(lines.length <= 16); + assert.ok(lines.some((line) => line.includes("of 30 runs"))); +}); + +test("dashboard records explicit Plan Approval through the engine", async () => { + const workflow = run({ + planArtifact: { + path: "/state/artifacts/plan.md", + revision: 3, + digest: "digest", + }, + }); + const commands: WorkflowCommand[] = []; + const selections = [ + "Record Gate Decision", + "Approve plan · UI impact minor", + ]; + const ctx = { + hasUI: true, + mode: "tui", + sessionManager: { + getSessionId: () => "session-1", + }, + ui: { + custom: async () => "run-1", + select: async () => selections.shift(), + confirm: async () => true, + notify: () => {}, + }, + } as unknown as ExtensionCommandContext; + const pi = { + getActiveTools: () => ["read"], + } as unknown as ExtensionAPI; + await openWorkflowDashboard( + pi, + { + store: { listRuns: async () => [workflow] } as never, + engineForContext: async () => + ({ + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + return view(workflow); + }, + }) as never, + }, + ctx, + ); + const decision = commands[0]; + assert.equal(decision?.action, "decide"); + assert.deepEqual( + decision?.action === "decide" ? decision.decision : undefined, + { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + ); +}); + +test("historical parent sessions receive inspection-only dashboard actions", async () => { + const workflow = run({ + parentSessionIds: ["session-1", "session-2"], + planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" }, + }); + let actions: string[] = []; + const ctx = { + hasUI: true, + mode: "tui", + sessionManager: { getSessionId: () => "session-2" }, + ui: { + custom: async () => "run-1", + select: async (_title: string, choices: string[]) => { + actions = choices; + return undefined; + }, + notify: () => {}, + }, + } as unknown as ExtensionCommandContext; + await openWorkflowDashboard( + { getActiveTools: () => ["read"] } as unknown as ExtensionAPI, + { + store: { listRuns: async () => [workflow] } as never, + engineForContext: () => Promise.reject(new Error("unused")), + }, + ctx, + ); + assert.equal(actions.includes("Record Gate Decision"), false); + assert.equal(actions.includes("Continue context transition"), false); + assert.equal(actions.includes("Open plan"), true); +}); + +test("dashboard opens retained report content on demand", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-dashboard-")); + try { + const reportPath = path.join(directory, "report.md"); + const reportContents = "# Completed report\n\nVerified."; + await writeFile(reportPath, reportContents, "utf8"); + const workflow = run({ + status: "completed", + gates: [], + reportArtifact: { + path: reportPath, + digest: createHash("sha256").update(reportContents).digest("hex"), + revision: 12, + }, + }); + let opened = ""; + const ctx = { + hasUI: true, + mode: "tui", + sessionManager: { getSessionId: () => "session-1" }, + ui: { + custom: async () => "run-1", + select: async () => "Open report", + editor: async (_title: string, contents: string) => { + opened = contents; + return contents; + }, + notify: () => {}, + }, + } as unknown as ExtensionCommandContext; + await openWorkflowDashboard( + { getActiveTools: () => ["read"] } as unknown as ExtensionAPI, + { + store: { listRuns: async () => [workflow] } as never, + engineForContext: () => Promise.reject(new Error("unused")), + }, + ctx, + ); + assert.match(opened, /Completed report[\s\S]*Verified/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("dashboard verifies and opens Assignment transcripts on demand", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-transcript-")); + try { + const transcriptPath = path.join(directory, "assignment.json"); + const transcriptContents = '[{"role":"assistant","text":"verified"}]\n'; + await writeFile(transcriptPath, transcriptContents, "utf8"); + const workflow = run({ + assignments: [ + { + id: "assignment-1", + stageId: "review", + role: "review", + profileName: "independent-reviewer", + profileDigest: "digest", + selection: { + eligibleProfiles: [], + selectedProfileName: "independent-reviewer", + rationale: "Review", + }, + status: "succeeded", + mutates: false, + ownedPaths: [], + transcriptArtifact: { + path: transcriptPath, + digest: createHash("sha256") + .update(transcriptContents) + .digest("hex"), + }, + }, + ], + }); + const selections = [ + "Browse Assignment transcripts", + "1. review/independent-reviewer · succeeded", + ]; + let opened = ""; + const ctx = { + hasUI: true, + mode: "tui", + sessionManager: { getSessionId: () => "session-1" }, + ui: { + custom: async () => "run-1", + select: async () => selections.shift(), + editor: async (_title: string, contents: string) => { + opened = contents; + return contents; + }, + notify: () => {}, + }, + } as unknown as ExtensionCommandContext; + await openWorkflowDashboard( + { getActiveTools: () => ["read"] } as unknown as ExtensionAPI, + { + store: { listRuns: async () => [workflow] } as never, + engineForContext: () => Promise.reject(new Error("unused")), + }, + ctx, + ); + assert.match(opened, /assistant.*verified/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("dashboard exposes continuation only for pending handoff intents", async () => { + const workflow = run({ + planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" }, + gates: [ + { + id: "context-gate", + kind: "implementation-context", + status: "open", + revision: 12, + openedAt: 1, + }, + ], + implementationContext: { + gateId: "context-gate", + runRevision: 12, + planRevision: 3, + preparationId: "prep-1", + activeParentSessionId: "session-1", + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }, + }); + const ctx = { + hasUI: true, + mode: "tui", + sessionManager: { getSessionId: () => "session-1" }, + ui: { + custom: async () => "run-1", + select: async () => "Continue context transition", + notify: () => {}, + }, + } as unknown as ExtensionCommandContext; + const result = await openWorkflowDashboard( + { getActiveTools: () => ["read"] } as unknown as ExtensionAPI, + { + store: { listRuns: async () => [workflow] } as never, + engineForContext: () => Promise.reject(new Error("unused")), + }, + ctx, + ); + assert.deepEqual(result, { continueRunId: "run-1" }); +}); diff --git a/extensions/pi-workflow/dashboard.ts b/extensions/pi-workflow/dashboard.ts new file mode 100644 index 0000000..3856718 --- /dev/null +++ b/extensions/pi-workflow/dashboard.ts @@ -0,0 +1,557 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { + Key, + matchesKey, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui"; +import { isValidContextContinuation } from "./continuation.ts"; +import type { + GateDecision, + PrototypeCandidate, + WorkflowRun, +} from "./domain.ts"; +import { + executeHostDecision, + type ContextTransitionRuntime, +} from "./context-transition.ts"; +import { + readVerifiedPlanArtifact, + readVerifiedReportArtifact, + readVerifiedTranscriptArtifact, +} from "./store.ts"; + +interface DashboardTheme { + fg( + color: + | "accent" + | "dim" + | "error" + | "muted" + | "success" + | "text" + | "warning", + text: string, + ): string; + bold(text: string): string; +} + +function statusColor(status: WorkflowRun["status"]) { + if (status === "completed") return "success" as const; + if (status === "failed" || status === "aborted") return "error" as const; + if (status === "waiting") return "warning" as const; + return "accent" as const; +} + +function activeStage(run: WorkflowRun) { + return ( + [...run.stages] + .reverse() + .find((stage) => + stage.status === "running" || stage.status === "waiting" + )?.kind ?? run.status + ); +} + +function selectedGate(run: WorkflowRun) { + return [...run.gates].reverse().find((gate) => gate.status === "open"); +} + +function stageLine(run: WorkflowRun, theme: DashboardTheme) { + return run.stages + .filter((stage) => + ["discover", "design", "prototype", "implement", "verify", "review"].includes( + stage.kind, + ), + ) + .map((stage) => { + const marker = + stage.status === "completed" + ? theme.fg("success", "✓") + : stage.status === "failed" + ? theme.fg("error", "×") + : stage.status === "running" || stage.status === "waiting" + ? theme.fg("accent", "●") + : theme.fg("dim", "·"); + return `${marker} ${stage.kind}`; + }) + .join(" "); +} + +export class FocusedWorkflowDashboard implements Component { + private readonly runs: WorkflowRun[]; + private readonly theme: DashboardTheme; + private readonly done: (result: string | null) => void; + private selected = 0; + private detail = false; + + constructor( + runs: WorkflowRun[], + theme: DashboardTheme, + done: (result: string | null) => void, + initialRunId?: string, + ) { + this.runs = runs; + this.theme = theme; + this.done = done; + const initial = initialRunId + ? runs.findIndex((run) => run.runId === initialRunId) + : -1; + if (initial >= 0) { + this.selected = initial; + this.detail = true; + } + } + + handleInput(data: string) { + if (matchesKey(data, Key.escape)) { + if (this.detail) this.detail = false; + else this.done(null); + return; + } + if (matchesKey(data, Key.up) && this.selected > 0) { + this.selected--; + return; + } + if (matchesKey(data, Key.down) && this.selected < this.runs.length - 1) { + this.selected++; + return; + } + if (matchesKey(data, Key.enter)) { + const run = this.runs[this.selected]; + if (!run) return; + if (this.detail) this.done(run.runId); + else this.detail = true; + } + } + + render(width: number) { + const lines = [this.theme.fg("accent", this.theme.bold("pi-workflow")), ""]; + if (this.runs.length === 0) { + lines.push(this.theme.fg("muted", "No Workflow Runs are attached to this session.")); + lines.push("", this.theme.fg("dim", "esc close")); + return lines.map((line) => truncateToWidth(line, width)); + } + const windowSize = 10; + const windowStart = Math.max( + 0, + Math.min( + this.selected - Math.floor(windowSize / 2), + Math.max(0, this.runs.length - windowSize), + ), + ); + const visibleRuns = this.runs.slice(windowStart, windowStart + windowSize); + for (const [visibleIndex, run] of visibleRuns.entries()) { + const index = windowStart + visibleIndex; + const prefix = index === this.selected ? "> " : " "; + const name = run.featureName ?? run.request.slice(0, 24); + const status = this.theme.fg( + statusColor(run.status), + run.status.toUpperCase().padEnd(9), + ); + lines.push( + `${prefix}${name.padEnd(24)} ${status} ${String(activeStage(run)).padEnd(20)} r${run.revision}`, + ); + } + if (this.runs.length > windowSize) { + lines.push( + this.theme.fg( + "dim", + `${windowStart + 1}-${windowStart + visibleRuns.length} of ${this.runs.length} runs`, + ), + ); + } + if (this.detail) { + const run = this.runs[this.selected]!; + const gate = selectedGate(run); + lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width)))); + lines.push(this.theme.bold(run.featureName ?? run.request)); + if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch)); + lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`); + if (gate) { + lines.push( + "", + `${this.theme.bold("Gate")} ${gate.kind} · revision ${gate.revision}`, + ); + } + if (run.planArtifact) { + lines.push(`${this.theme.bold("Plan")} ${run.planArtifact.path}`); + } + if (run.reportArtifact) { + lines.push(`${this.theme.bold("Report")} ${run.reportArtifact.path}`); + } + if (width >= 80 && run.workspace.root) { + lines.push(`${this.theme.bold("Worktree")} ${run.workspace.root}`); + } + lines.push( + "", + this.theme.fg("dim", "enter actions esc runs"), + ); + } else { + lines.push("", this.theme.fg("dim", "↑↓ run enter details esc close")); + } + return lines.map((line) => truncateToWidth(line, width)); + } + + invalidate() {} +} + +function runSummary(run: WorkflowRun) { + const gate = selectedGate(run); + return [ + `${run.runId} · ${run.status} · revision ${run.revision}`, + `Stage: ${activeStage(run)}`, + ...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []), + ...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []), + ...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []), + ...(run.planArtifact ? [`Plan: ${run.planArtifact.path}`] : []), + ...(run.reportArtifact ? [`Report: ${run.reportArtifact.path}`] : []), + ].join("\n"); +} + +function idempotencyKey(run: WorkflowRun, decision: GateDecision) { + return `dashboard-${run.runId}-${run.revision}-${decision.kind}-${randomUUID()}`; +} + +async function decide( + pi: Pick, + runtime: ContextTransitionRuntime, + ctx: ExtensionContext, + run: WorkflowRun, + decision: GateDecision, +) { + const gate = selectedGate(run); + if (!gate) throw new Error("Workflow Run has no open Gate"); + const engine = await runtime.engineForContext(ctx); + return executeHostDecision(pi, engine, { + action: "decide", + runId: run.runId, + gateId: gate.id, + expectedRevision: run.revision, + decision, + idempotencyKey: idempotencyKey(run, decision), + }); +} + +async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) { + const gate = selectedGate(run); + const payload = gate?.payload; + if (!payload || typeof payload !== "object") return undefined; + const questions = (payload as { questions?: unknown }).questions; + if (!Array.isArray(questions)) return undefined; + const answers: Record = {}; + for (const item of questions) { + if (!item || typeof item !== "object") continue; + const id = (item as { id?: unknown }).id; + const prompt = (item as { prompt?: unknown }).prompt; + if (typeof id !== "string" || typeof prompt !== "string") continue; + const answer = await ctx.ui.input(prompt, "Required answer"); + if (!answer?.trim()) return undefined; + answers[id] = answer.trim(); + } + return { kind: "answer-questions" as const, answers }; +} + +async function planDecision(ctx: ExtensionContext, run: WorkflowRun) { + if (!run.planArtifact || !run.uiImpact) return undefined; + const action = await ctx.ui.select("Plan Approval", [ + "Inspect plan", + `Approve plan · UI impact ${run.uiImpact}`, + "Request revision", + ]); + if (action === "Inspect plan") { + const plan = await readVerifiedPlanArtifact(run.planArtifact); + await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024)); + return undefined; + } + if (action === "Request revision") { + const feedback = await ctx.ui.editor("Plan revision feedback", ""); + return feedback?.trim() + ? { kind: "revise-plan" as const, feedback: feedback.trim() } + : undefined; + } + if (action?.startsWith("Approve plan")) { + const approved = await ctx.ui.confirm( + "Approve plan", + `Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`, + ); + return approved + ? { + kind: "approve-plan" as const, + approved: true as const, + acceptedUiImpact: run.uiImpact, + } + : undefined; + } + return undefined; +} + +async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) { + const candidates = run.prototypeCandidates.slice(-3); + const items = candidates.map( + (candidate, index) => + `${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`, + ); + const action = await ctx.ui.select("Prototype Acceptance", [ + ...items, + "Request refinement", + "Abort run", + ]); + if (action === "Request refinement") { + const feedback = await ctx.ui.editor("Prototype refinement feedback", ""); + return feedback?.trim() + ? { kind: "refine-prototypes" as const, feedback: feedback.trim() } + : undefined; + } + if (action === "Abort run") return "abort" as const; + const index = items.indexOf(action ?? ""); + const candidate: PrototypeCandidate | undefined = candidates[index]; + if (!candidate) return undefined; + ctx.ui.notify( + [ + candidate.launchInstructions, + `Route: ${candidate.route}`, + `Evidence: ${candidate.retainedArtifacts.join(", ")}`, + ].join("\n"), + "info", + ); + const browserReviewed = await ctx.ui.confirm( + "Accept prototype", + "Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.", + ); + if (!browserReviewed) return undefined; + const feedback = await ctx.ui.input("Acceptance feedback", "Optional"); + return { + kind: "accept-prototype" as const, + candidateId: candidate.id, + candidateRevision: candidate.revision, + browserReviewed: true as const, + ...(feedback?.trim() ? { feedback: feedback.trim() } : {}), + }; +} + +async function implementationContextDecision(ctx: ExtensionContext) { + const strategy = await ctx.ui.select("Implementation Context", [ + "Fresh plan-seeded session", + "Compact current session", + "Keep current context unchanged", + ]); + if (strategy === "Fresh plan-seeded session") { + return { kind: "select-implementation-context" as const, strategy: "fresh-session" as const }; + } + if (strategy === "Compact current session") { + return { kind: "select-implementation-context" as const, strategy: "smart-compact" as const }; + } + if (strategy === "Keep current context unchanged") { + return { kind: "select-implementation-context" as const, strategy: "current-session" as const }; + } + return undefined; +} + +async function recoveryDecision(ctx: ExtensionContext) { + const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]); + if (!resolution) return undefined; + return { + kind: "recover" as const, + resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort", + }; +} + +async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) { + const gate = selectedGate(run); + if (!gate) return undefined; + if (gate.kind === "questions") return questionDecision(ctx, run); + if (gate.kind === "plan-approval") return planDecision(ctx, run); + if (gate.kind === "prototype-acceptance") return prototypeDecision(ctx, run); + if (gate.kind === "implementation-context") { + return implementationContextDecision(ctx); + } + return recoveryDecision(ctx); +} + +function validContinuation(run: WorkflowRun, sessionId?: string) { + return isValidContextContinuation( + { ...run, openGate: selectedGate(run) }, + sessionId, + ); +} + +function isActiveParent(run: WorkflowRun, sessionId?: string) { + return Boolean( + sessionId && run.lease?.activeParentSessionId === sessionId, + ); +} + +async function browsePrototypeArtifacts( + ctx: ExtensionContext, + run: WorkflowRun, +) { + const candidates = run.prototypeCandidates; + const labels = candidates.map( + (candidate, index) => + `${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`, + ); + const selected = await ctx.ui.select("Prototype evidence", labels); + const candidate = candidates[labels.indexOf(selected ?? "")]; + if (!candidate) return; + ctx.ui.notify( + `${candidate.launchInstructions}\nRoute: ${candidate.route}`, + "info", + ); + const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts]; + const artifact = await ctx.ui.select("Retained artifact", artifacts); + if (!artifact) return; + if (artifact.endsWith(".json")) { + const contents = await readFile(artifact, "utf8"); + await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024)); + } else { + ctx.ui.notify(artifact, "info"); + } +} + +async function browseAssignmentTranscripts( + ctx: ExtensionContext, + run: WorkflowRun, +) { + const assignments = run.assignments.filter( + (assignment) => assignment.transcriptArtifact, + ); + const labels = assignments.map( + (assignment, index) => + `${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`, + ); + const selected = await ctx.ui.select("Assignment transcripts", labels); + const assignment = assignments[labels.indexOf(selected ?? "")]; + if (!assignment?.transcriptArtifact) return; + const transcript = await readVerifiedTranscriptArtifact( + assignment.transcriptArtifact, + ); + await ctx.ui.editor( + `${assignment.role} transcript`, + transcript.slice(0, 32 * 1024), + ); +} + +async function actionSheet( + pi: ExtensionAPI, + runtime: ContextTransitionRuntime, + ctx: ExtensionCommandContext, + run: WorkflowRun, +) { + const actions = [ + "Inspect run", + ...(selectedGate(run) && + isActiveParent(run, ctx.sessionManager.getSessionId()) + ? ["Record Gate Decision"] + : []), + ...(validContinuation(run, ctx.sessionManager.getSessionId()) + ? ["Continue context transition"] + : []), + ...(run.planArtifact ? ["Open plan"] : []), + ...(run.reportArtifact ? ["Open report"] : []), + ...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []), + ...(run.assignments.some((assignment) => assignment.transcriptArtifact) + ? ["Browse Assignment transcripts"] + : []), + ...(run.workspace.root ? ["Show worktree path"] : []), + ]; + const action = await ctx.ui.select(run.featureName ?? run.runId, actions); + if (action === "Inspect run") ctx.ui.notify(runSummary(run), "info"); + else if (action === "Open plan") { + const plan = await readVerifiedPlanArtifact(run.planArtifact!); + await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024)); + } else if (action === "Open report") { + const report = await readVerifiedReportArtifact(run.reportArtifact!); + await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024)); + } else if (action === "Browse prototype evidence") { + await browsePrototypeArtifacts(ctx, run); + } else if (action === "Browse Assignment transcripts") { + await browseAssignmentTranscripts(ctx, run); + } else if (action === "Show worktree path") { + ctx.ui.notify(run.workspace.root!, "info"); + } + else if (action === "Continue context transition") { + return { continueRunId: run.runId }; + } else if (action === "Record Gate Decision") { + const decision = await gateDecision(ctx, run); + if (decision === "abort") { + const engine = await runtime.engineForContext(ctx); + await engine.execute({ + action: "abort", + runId: run.runId, + expectedRevision: run.revision, + idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`, + }); + } else if (decision) { + const result = await decide(pi, runtime, ctx, run, decision); + if ( + decision.kind === "select-implementation-context" && + decision.strategy === "smart-compact" + ) { + ctx.ui.notify( + `Workflow ${run.runId} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`, + "info", + ); + } else { + ctx.ui.notify(`Workflow ${run.runId}: ${result.status}`, "info"); + } + } + } + return {}; +} + +export interface DashboardResult { + continueRunId?: string; +} + +export async function openWorkflowDashboard( + pi: ExtensionAPI, + runtime: ContextTransitionRuntime, + ctx: ExtensionCommandContext, + initialRunId?: string, +): Promise { + const sessionId = ctx.sessionManager.getSessionId(); + const runs = (await runtime.store.listRuns()) + .filter((run) => !sessionId || run.parentSessionIds.includes(sessionId)) + .sort((left, right) => right.revision - left.revision); + if (!ctx.hasUI || ctx.mode !== "tui") { + const visibleRuns = runs.slice(0, 20); + const omitted = runs.length - visibleRuns.length; + ctx.ui.notify( + runs.length > 0 + ? `${visibleRuns.map(runSummary).join("\n\n")}${ + omitted > 0 ? `\n\n[${omitted} older runs omitted]` : "" + }` + : "No Workflow Runs.", + "info", + ); + return {}; + } + const selected = await ctx.ui.custom( + (tui, theme, _keybindings, done) => { + const dashboard = new FocusedWorkflowDashboard( + runs, + theme as DashboardTheme, + done, + initialRunId, + ); + return { + render: (width) => dashboard.render(width), + invalidate: () => dashboard.invalidate(), + handleInput: (data) => { + dashboard.handleInput(data); + tui.requestRender(); + }, + }; + }, + ); + if (!selected) return {}; + const run = runs.find((candidate) => candidate.runId === selected); + return run ? actionSheet(pi, runtime, ctx, run) : {}; +} diff --git a/extensions/pi-workflow/domain.ts b/extensions/pi-workflow/domain.ts new file mode 100644 index 0000000..491a979 --- /dev/null +++ b/extensions/pi-workflow/domain.ts @@ -0,0 +1,465 @@ +export const PROFILE_ROLES = [ + "discover", + "design", + "prototype", + "implement", + "verify", + "review", +] as const; + +export type ProfileRole = (typeof PROFILE_ROLES)[number]; +export type AssignmentStageKind = ProfileRole; +export type WorkflowStageKind = + | "initialize" + | AssignmentStageKind + | "remediate" + | "complete" + | `custom:${string}`; + +export const REASONING_EFFORTS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; + +export interface ProfileSelection { + stages: AssignmentStageKind[]; + anyTags: string[]; + allTags: string[]; + pathGlobs: string[]; + keywords: string[]; +} + +export interface AgentProfile { + name: string; + description: string; + roles: ProfileRole[]; + model?: string; + provider?: string; + effort?: ReasoningEffort; + tools?: string[]; + selection: ProfileSelection; + instructions: string; + source: "bundled" | "project"; + sourcePath: string; + resolvedPath: string; + digest: string; +} + +export interface ProfileDiagnostic { + sourcePath: string; + severity: "warning" | "error"; + message: string; +} + +export interface ProfileCatalog { + profiles: AgentProfile[]; + diagnostics: ProfileDiagnostic[]; +} + +export interface WorkflowFacts { + stage: AssignmentStageKind; + taskKind: "feature" | "analysis"; + requestTerms: string[]; + candidatePaths: string[]; + languages: string[]; + tags: string[]; + riskTags: string[]; +} + +export interface EligibilityResult { + profile: AgentProfile; + eligible: boolean; + reasons: string[]; + specificity: number; +} + +export type RunStatus = + | "running" + | "waiting" + | "completed" + | "failed" + | "aborted"; +export type AssignmentStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "interrupted"; +export type StageStatus = + | "pending" + | "running" + | "waiting" + | "completed" + | "failed"; +export type GateKind = + | "questions" + | "plan-approval" + | "prototype-acceptance" + | "implementation-context" + | "recovery"; +export type UiImpact = "none" | "minor" | "non-trivial"; + +export type GateDecision = + | { kind: "answer-questions"; answers: Record } + | { + kind: "approve-plan"; + approved: true; + acceptedUiImpact: UiImpact; + comment?: string; + } + | { kind: "revise-plan"; feedback: string } + | { + kind: "accept-prototype"; + candidateId: string; + candidateRevision: number; + browserReviewed: true; + feedback?: string; + } + | { kind: "refine-prototypes"; feedback: string } + | { + kind: "select-implementation-context"; + strategy: + | "fresh-session" + | "smart-compact" + | "current-session"; + } + | { + kind: "report-context-preparation"; + preparationId: string; + planRevision: number; + method: "smart_compact"; + outcome: "unavailable" | "succeeded" | "no-op"; + } + | { + kind: "report-context-preparation"; + preparationId: string; + planRevision: number; + method: "smart_compact"; + outcome: "failed"; + error: string; + } + | { kind: "recover"; resolution: "retry" | "inspect" | "abort" }; + +export interface RunLease { + processId: number; + activeParentSessionId: string; + heartbeatAt: number; + worktreeIdentity?: string; +} + +export interface WorkflowWorkspace { + state: "not-required" | "creating" | "ready" | "failed"; + sourceRoot: string; + root?: string; + branch?: string; + baseHead?: string; + error?: string; +} + +export interface WorkflowStage { + id: string; + kind: WorkflowStageKind; + status: StageStatus; + startedAt?: number; + finishedAt?: number; + output?: unknown; +} + +export interface WorkflowGate { + id: string; + kind: GateKind; + status: "open" | "satisfied" | "rejected"; + revision: number; + openedAt: number; + payload?: unknown; +} + +export interface WorkflowDecision { + gateId: string; + revision: number; + idempotencyKey: string; + decidedAt: number; + value: GateDecision; +} + +export interface ProfileSelectionEvidence { + profileName: string; + profileDigest: string; + eligible: boolean; + reasons: string[]; + specificity: number; +} + +export interface AssignmentSelection { + eligibleProfiles: ProfileSelectionEvidence[]; + selectedProfileName: string; + rationale: string; +} + +export interface StageEligibilityEvidence { + role: ProfileRole; + candidates: ProfileSelectionEvidence[]; +} + +export interface AssignmentTranscriptArtifact { + path: string; + digest: string; +} + +export interface AssignmentGitState { + root: string; + branch: string; + head: string; + commonDirectory: string; + gitDirectory: string; + worktreeListDigest: string; + worktreeMetadataDigest: string; + status: string; + changedPaths: string[]; + pathStatuses: Record; + pathDigests: Record; +} + +export interface VerificationCommand { + id: string; + executable: string; + args: string[]; + ownedPaths: string[]; + kind: "test" | "typecheck" | "lint" | "build" | "browser" | "generation"; + status: + | "pending" + | "running" + | "succeeded" + | "failed" + | "interrupted" + | "superseded"; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +} + +export interface VerificationEvidence { + commands: VerificationCommand[]; + focusedBehavioralTests: boolean; + verifierSucceeded: boolean; +} + +export interface ReviewAxis { + verdict: "approved" | "changes_requested"; + findings: Array<{ severity: string; message: string; path?: string }>; +} + +export interface ReviewVerdict { + specification: ReviewAxis; + standards: ReviewAxis; +} + +export interface WorkflowAssignment { + id: string; + stageId: string; + role: ProfileRole | "remediate"; + profileName: string; + profileDigest: string; + selection: AssignmentSelection; + status: AssignmentStatus; + mutates: boolean; + ownedPaths: string[]; + scriptRequestId?: number; + scriptRequestFingerprint?: string; + executionToken?: string; + childSessionId?: string; + effectiveProvider?: string; + effectiveModel?: string; + effectiveEffort?: string; + contextCapacity?: number; + transcriptArtifact?: AssignmentTranscriptArtifact; + gitBefore?: AssignmentGitState; + gitAfter?: AssignmentGitState; + result?: unknown; + error?: string; +} + +export interface PrototypeCandidate { + id: string; + revision: number; + assignmentId: string; + profileName: string; + status: "succeeded"; + path: string; + route: string; + structuralRationale: string; + responsiveChecks: string[]; + browserEvidence: string[]; + launchInstructions: string; + visualDigest: string; + artifactPath: string; + retainedArtifacts: string[]; +} + +export interface DeliveryRecord { + key: string; + state: "pending" | "sent"; + createdAt: number; + sentAt?: number; +} + +export interface WorkflowReportArtifact { + path: string; + digest: string; + revision: number; +} + +export interface PlanArtifact { + path: string; + revision: number; + digest: string; +} + +export interface ImplementationContextState { + gateId: string; + runRevision: number; + planRevision: number; + preparationId: string; + activeParentSessionId: string; + sourceParentSessionId?: string; + sourceSessionFile?: string; + targetSessionFile?: string; + claimedAt?: number; + intentCreatedAt?: number; + intentExpiresAt?: number; + strategy?: "fresh-session" | "smart-compact" | "current-session"; + status: + | "waiting" + | "handoff-pending" + | "claimed-target-awaiting-withSession" + | "smart-compact-pending" + | "normal-compact-pending" + | "normal-compact-running" + | "ready" + | "failed"; + method?: + | "fresh-session" + | "smart_compact" + | "normal-compact" + | "unchanged"; + outcome?: "unavailable" | "succeeded" | "no-op" | "failed"; + error?: string; +} + +export interface WorkflowError { + code: string; + message: string; + stageId?: string; + assignmentId?: string; +} + +export type CommandContinuation = + | "none" + | "design" + | "prototype" + | "implementation" + | "recovery"; + +export interface CommandReceipt { + key: string; + action: "decide" | "abort"; + fingerprint: string; + status: "pending" | "completed"; + acceptedRevision: number; + continuation?: CommandContinuation; + result?: unknown; +} + +export type FeatureScriptInsertionPoint = + | "before-design" + | "after-design" + | "before-review"; + +export interface WorkflowScriptState { + source: string; + args?: unknown; + insertionPoint?: FeatureScriptInsertionPoint; + activeToolNames: string[]; + status: "pending" | "running" | "completed" | "failed"; + stages: string[]; + executed: boolean; + result?: unknown; + error?: string; +} + +export interface WorkflowRun { + runId: string; + revision: number; + mode: "feature" | "analysis"; + script?: WorkflowScriptState; + status: RunStatus; + background: boolean; + request: string; + featureName?: string; + parentSessionIds: string[]; + planArtifact?: PlanArtifact; + reportArtifact?: WorkflowReportArtifact; + implementationContext?: ImplementationContextState; + lease?: RunLease; + workspace: WorkflowWorkspace; + effectiveConfig: WorkflowConfig; + pinnedProfiles: AgentProfile[]; + preflightEligibility: StageEligibilityEvidence[]; + facts: WorkflowFacts; + stages: WorkflowStage[]; + gates: WorkflowGate[]; + decisions: WorkflowDecision[]; + assignments: WorkflowAssignment[]; + prototypeCandidates: PrototypeCandidate[]; + prototypeCleanupCompleted: boolean; + deliveries: DeliveryRecord[]; + commandReceipts: CommandReceipt[]; + uiImpact?: UiImpact; + uiImpactEvidence: string[]; + affectedUiSurfaces: string[]; + mutatorProfileNames: string[]; + reviewCycles: number; + verificationCycles: number; + verificationEvidence?: VerificationEvidence; + reviewVerdict?: ReviewVerdict; + error?: WorkflowError; +} + +export const ANALYSIS_TOOL_ALLOWLIST = ["read", "grep", "find", "ls"] as const; +export const MAX_CONCURRENCY = 32; +export const MAX_ASSIGNMENTS_PER_RUN = 1_024; +export const MAX_REVIEW_CYCLES = 20; + +export interface WorkflowConfig { + concurrency: { + default: number; + ceiling: number; + }; + maxAssignmentsPerRun: number; + maxReviewCycles: number; + analysisTools: string[]; + worktrees: { + root: string; + }; +} + +export const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = { + concurrency: { + default: 8, + ceiling: MAX_CONCURRENCY, + }, + maxAssignmentsPerRun: 128, + maxReviewCycles: 3, + analysisTools: [...ANALYSIS_TOOL_ALLOWLIST], + worktrees: { + root: ".worktrees", + }, +}; diff --git a/extensions/pi-workflow/engine.test.ts b/extensions/pi-workflow/engine.test.ts new file mode 100644 index 0000000..6275081 --- /dev/null +++ b/extensions/pi-workflow/engine.test.ts @@ -0,0 +1,3502 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { + DEFAULT_WORKFLOW_CONFIG, + type AgentProfile, + type GateDecision, + type GateKind, + type WorkflowAssignment, + type WorkflowRun, +} from "./domain.ts"; +import { + decisionIsLegal, + WorkflowEngine, + type AssignmentExecutor, + type AssignmentRequest, + type HostCommandExecutor, + type StartWorkflowCommand, + type WorkflowCommand, + type WorkspaceManager, +} from "./engine.ts"; +import { loadAgentProfiles } from "./profiles.ts"; +import { runScriptSandbox } from "./script-runtime.ts"; +import { WorkflowStore } from "./store.ts"; +import { + GitWorkspaceManager, + WorkspaceSafetyError, + type PrepareWorkspaceOptions, + type WorkspacePlan, +} from "./workspace.ts"; + +const execFileAsync = promisify(execFile); + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-engine-")); + try { + await run(directory); + } finally { + await rm(directory, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 20, + }); + } +} + +class ScriptedExecutor implements AssignmentExecutor { + readonly requests: AssignmentRequest[] = []; + readonly outputs = new Map(); + + enqueue(role: WorkflowAssignment["role"], output: unknown) { + const queued = this.outputs.get(role) ?? []; + queued.push(output); + this.outputs.set(role, queued); + } + + async run(request: AssignmentRequest) { + this.requests.push(structuredClone(request)); + const queued = this.outputs.get(request.role) ?? []; + if (queued.length === 0) + throw new Error(`No scripted ${request.role} result`); + const output = queued.shift(); + if (output instanceof Error) throw output; + return structuredClone(output); + } +} + +class BlockingExecutor implements AssignmentExecutor { + readonly requests: AssignmentRequest[] = []; + readonly started: Promise; + private readonly output: unknown; + private releaseRun?: () => void; + private markStarted?: () => void; + + constructor(output: unknown) { + this.output = output; + this.started = new Promise((resolve) => { + this.markStarted = resolve; + }); + } + + async run(request: AssignmentRequest) { + this.requests.push(request); + this.markStarted?.(); + await new Promise((resolve) => { + this.releaseRun = resolve; + }); + return structuredClone(this.output); + } + + release() { + this.releaseRun?.(); + } +} + +class FakeWorkspace implements WorkspaceManager { + prepareCalls = 0; + createCalls = 0; + reconcileCalls = 0; + branchExists = false; + createError?: Error; + reconcileState: WorkflowRun["workspace"]["state"] = "ready"; + beforeCreate?: (plan: WorkspacePlan) => Promise; + + async prepare(options: PrepareWorkspaceOptions): Promise { + this.prepareCalls++; + return { + sourceRoot: options.sourceRoot, + worktreeRoot: path.join(options.sourceRoot, options.configuredRoot), + worktreePath: path.join( + options.sourceRoot, + options.configuredRoot, + `pi-workflow-${options.featureName}-${options.runId}`, + ), + branch: `feature/${options.featureName}`, + baseHead: "abc123", + branchExists: this.branchExists, + }; + } + + async create(plan: WorkspacePlan) { + this.createCalls++; + await this.beforeCreate?.(plan); + if (this.createError) throw this.createError; + await mkdir(plan.worktreePath, { recursive: true }); + return { + state: "ready" as const, + sourceRoot: plan.sourceRoot, + root: plan.worktreePath, + branch: plan.branch, + baseHead: plan.baseHead, + }; + } + + async reconcile(workspace: WorkflowRun["workspace"]) { + this.reconcileCalls++; + return { + ...workspace, + state: this.reconcileState, + ...(this.reconcileState === "failed" + ? { error: "reconciliation failed" } + : { error: undefined }), + }; + } +} + +interface Fixture { + store: WorkflowStore; + workspace: FakeWorkspace; + executor: ScriptedExecutor; + profiles: AgentProfile[]; + engine( + sessionId?: string, + overrides?: Partial[0]>, + ): WorkflowEngine; +} + +async function fixture( + directory: string, + storeOptions: ConstructorParameters[1] = {}, +): Promise { + const store = new WorkflowStore(path.join(directory, "state"), storeOptions); + const workspace = new FakeWorkspace(); + const executor = new ScriptedExecutor(); + const profiles = ( + await loadAgentProfiles({ cwd: directory, projectTrusted: false }) + ).profiles; + let id = 0; + const createId = () => `id-${++id}`; + return { + store, + workspace, + executor, + profiles, + engine(sessionId = "session-1", overrides = {}) { + return new WorkflowEngine({ + store, + workspace, + executor, + hostCommands: { + async run() { + return { result: { code: 0, stdout: "passed", stderr: "" } }; + }, + }, + reviewDiff: { + async read() { + return "diff --git a/src/index.ts b/src/index.ts"; + }, + }, + prototypeValidator: { + async validateCandidate({ output }) { + return { + artifactFiles: [], + visualDigest: createHash("sha256") + .update(output.path) + .digest("hex"), + }; + }, + async validateCleanup() {}, + }, + sourceRoot: directory, + parentSessionId: sessionId, + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + profiles, + now: () => 10_000, + createId, + processId: 100, + isProcessAlive: () => true, + ...overrides, + }); + }, + }; +} + +function startCommand( + overrides: Partial> = {}, +): StartWorkflowCommand { + return { + action: "start" as const, + request: "Build a searchable dashboard", + featureName: "searchable-dashboard", + idempotencyKey: "start-key", + ...overrides, + }; +} + +function discoveryWithQuestions() { + return { + questions: [{ id: "data-source", prompt: "Which data source?" }], + candidatePaths: ["src/routes/dashboard.ts"], + languages: ["typescript"], + tags: ["frontend", "ui"], + riskTags: ["migration"], + }; +} + +function enqueueSuccessfulPipeline(setup: Fixture) { + setup.executor.enqueue("implement", { + summary: "Implemented the approved plan", + changedPaths: ["src/index.ts"], + focusedTests: ["feature behavior"], + }); + setup.executor.enqueue("verify", { + succeeded: true, + focusedBehavioralTests: true, + summary: "Focused behavior verified", + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); +} + +function design(uiImpact: "none" | "minor" | "non-trivial" = "minor") { + return { + plan: "# Approved plan\n\nImplement the dashboard.", + uiImpact, + uiImpactEvidence: ["Planner classified the dashboard change"], + affectedUiSurfaces: ["Dashboard"], + acceptanceCriteria: ["Changed behavior has focused tests"], + verificationCommands: [ + { + executable: "npm", + args: ["test"], + ownedPaths: [], + kind: "test", + }, + ], + assignments: [ + { + role: "implement", + profileName: "feature-implementer", + rationale: "Implement the approved plan", + mutates: true, + ownedPaths: ["src"], + }, + { + role: "verify", + profileName: "feature-verifier", + rationale: "Verify the implementation", + mutates: false, + ownedPaths: [], + }, + { + role: "review", + profileName: "independent-reviewer", + rationale: "Review independently", + mutates: false, + ownedPaths: [], + }, + ...(uiImpact === "non-trivial" + ? [ + { + role: "prototype", + profileName: "ui-prototyper", + rationale: "Prototype the non-trivial UI", + mutates: true, + ownedPaths: ["prototypes"], + }, + ] + : []), + ], + }; +} + +test("analysis scripts run persisted read-only Assignments with tool intersection", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const engine = setup.engine("session-1", { + scriptRuntime: { run: runScriptSandbox }, + }); + const command = { + action: "start-analysis" as const, + request: "Audit persistence behavior", + source: ` + stage("Persistence audit"); + const result = await assign("review", "Inspect persistence behavior"); + return result.structured; + `, + args: { scope: "store" }, + activeToolNames: ["read", "grep", "bash"], + idempotencyKey: "analysis-script-key", + }; + const completed = await engine.execute(command); + assert.equal(completed.status, "completed"); + assert.equal(setup.workspace.prepareCalls, 0); + const persisted = await setup.store.loadRun(completed.runId); + assert.equal(persisted.mode, "analysis"); + assert.equal(persisted.script?.status, "completed"); + assert.deepEqual(persisted.script?.stages, ["Persistence audit"]); + assert.equal(persisted.assignments.length, 1); + assert.equal(persisted.assignments[0]?.mutates, false); + assert.deepEqual(setup.executor.requests[0]?.allowedReadTools, ["read", "grep"]); + assert.deepEqual(await engine.execute(command), completed); + assert.equal(setup.executor.requests.length, 1); + }); +}); + +test("script Recovery reuses completed request IDs without orphaning interrupted work", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const engine = setup.engine("session-1", { + scriptRuntime: { run: runScriptSandbox }, + }); + const completed = await engine.execute({ + action: "start-analysis", + request: "Audit recovery", + source: `return await assign("review", "First request");`, + activeToolNames: ["read"], + idempotencyKey: "analysis-recovery-start", + }); + const interrupted = await setup.store.loadRun(completed.runId); + assert.ok(interrupted.script); + interrupted.script.source = ` + const first = await assign("review", "First request"); + const second = await assign("review", "Second request"); + return [first, second]; + `; + interrupted.script.status = "running"; + interrupted.script.executed = false; + interrupted.status = "waiting"; + const firstAssignment = interrupted.assignments[0]!; + interrupted.assignments.push({ + ...structuredClone(firstAssignment), + id: "interrupted-script-request", + status: "interrupted", + result: undefined, + scriptRequestId: 2, + scriptRequestFingerprint: "interrupted", + }); + const recoveryGateId = "script-recovery-gate"; + interrupted.gates.push({ + id: recoveryGateId, + kind: "recovery", + status: "open", + revision: interrupted.revision + 1, + openedAt: 10_000, + payload: { interruptedAssignmentIds: ["interrupted-script-request"] }, + }); + interrupted.revision++; + await setup.store.saveRun(interrupted, interrupted.revision - 1); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const recovered = await engine.execute({ + action: "decide", + runId: interrupted.runId, + gateId: recoveryGateId, + expectedRevision: interrupted.revision, + decision: { kind: "recover", resolution: "retry" }, + idempotencyKey: "retry-analysis-script", + }); + assert.equal(recovered.status, "completed", recovered.error?.message); + assert.equal(setup.executor.requests.length, 2); + const persisted = await setup.store.loadRun(recovered.runId); + assert.equal( + persisted.assignments.find( + (assignment) => assignment.id === "interrupted-script-request", + )?.status, + "interrupted", + ); + assert.equal( + persisted.assignments.filter( + (assignment) => assignment.scriptRequestId === 1, + ).length, + 1, + ); + }); +}); + +test("script abort waits for active Assignments and fails closed on timeout", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const blocking = new BlockingExecutor({ + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const engine = setup.engine("session-1", { + executor: blocking, + scriptRuntime: { run: runScriptSandbox }, + assignmentAbortTimeoutMs: 10, + }); + const start = engine.execute({ + action: "start-analysis", + request: "Audit cancellation", + source: `return await assign("review", "Block until released");`, + activeToolNames: ["read"], + idempotencyKey: "blocking-analysis", + }); + await blocking.started; + const [running] = await setup.store.listRuns(); + assert.ok(running); + await assert.rejects( + engine.execute({ + action: "abort", + runId: running.runId, + expectedRevision: running.revision, + idempotencyKey: "abort-blocking-analysis", + }), + /Timed out stopping active script/, + ); + assert.notEqual((await setup.store.loadRun(running.runId)).status, "aborted"); + blocking.release(); + const failed = await start; + assert.equal(failed.status, "failed"); + assert.match(failed.error?.message ?? "", /aborted/); + }); +}); + +test("feature scripts are insertion-bound and cannot bypass Plan Approval", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + setup.executor.enqueue("design", design("minor")); + const planned = await setup.engine("session-1", { + scriptRuntime: { run: runScriptSandbox }, + }).execute( + startCommand({ + script: { + source: ` + stage("Pre-design audit"); + return await assign("review", "Inspect design precedents"); + `, + insertionPoint: "before-design", + activeToolNames: ["read", "grep", "find", "ls", "bash"], + }, + }), + ); + assert.equal(planned.status, "waiting", planned.error?.message); + assert.equal(planned.openGate?.kind, "plan-approval"); + assert.deepEqual( + setup.executor.requests.map((request) => request.role), + ["discover", "review", "design"], + ); + const persisted = await setup.store.loadRun(planned.runId); + assert.equal(persisted.script?.executed, true); + assert.equal( + persisted.assignments + .filter((assignment) => + assignment.selection.rationale.startsWith("Custom read-only script"), + ) + .some((assignment) => assignment.mutates), + false, + ); + }); +}); + +test("after-design feature scripts still leave Plan Approval mandatory", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const planned = await setup.engine("session-1", { + scriptRuntime: { run: runScriptSandbox }, + }).execute( + startCommand({ + script: { + source: `return await assign("review", "Audit the completed design");`, + insertionPoint: "after-design", + activeToolNames: ["read", "grep", "find", "ls"], + }, + }), + ); + assert.equal(planned.status, "waiting"); + assert.equal(planned.openGate?.kind, "plan-approval"); + assert.deepEqual( + setup.executor.requests.map((request) => request.role), + ["discover", "design", "review"], + ); + }); +}); + +test("before-review feature scripts cannot replace mandatory verification or review", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + setup.executor.enqueue("implement", { + summary: "Implemented feature", + changedPaths: ["src/index.ts"], + focusedTests: ["feature behavior"], + }); + setup.executor.enqueue("verify", { + succeeded: true, + focusedBehavioralTests: true, + summary: "Verified feature", + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const engine = setup.engine("session-1", { + scriptRuntime: { run: runScriptSandbox }, + }); + const planned = await engine.execute( + startCommand({ + script: { + source: `return await assign("review", "Perform pre-review analysis");`, + insertionPoint: "before-review", + activeToolNames: ["read", "grep", "find", "ls"], + }, + }), + ); + assert.equal( + (await setup.store.loadRun(planned.runId)).script?.executed, + false, + ); + const approved = await engine.execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-before-review-script", + }); + const completed = await engine.execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "current-session", + }, + idempotencyKey: "context-before-review-script", + }); + assert.equal(completed.status, "completed"); + const roles = setup.executor.requests.map((request) => request.role); + assert.deepEqual(roles, [ + "discover", + "design", + "implement", + "verify", + "review", + "review", + ]); + assert.equal( + (await setup.store.loadRun(completed.runId)).script?.executed, + true, + ); + }); +}); + +test("feature scripts reject mutating roles and invalid insertion points", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + const failed = await setup.engine("session-1", { + scriptRuntime: { run: runScriptSandbox }, + }).execute( + startCommand({ + script: { + source: `return await assign("implement", "Mutate before approval");`, + insertionPoint: "before-design", + activeToolNames: ["read"], + }, + }), + ); + assert.equal(failed.status, "failed"); + assert.match(failed.error?.message ?? "", /not read-only or supported/); + await assert.rejects( + setup.engine().execute({ + ...startCommand({ idempotencyKey: "invalid-insertion" }), + script: { + source: "return null", + insertionPoint: "during-implementation", + activeToolNames: ["read"], + }, + } as unknown as WorkflowCommand), + /insertionPoint is invalid/, + ); + assert.equal(setup.workspace.prepareCalls, 1); + }); +}); + +test("background feature starts return after durable publication and continue to a Gate", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const blocking = new BlockingExecutor(discoveryWithQuestions()); + const persistedDeliveries: string[] = []; + const engine = setup.engine("session-1", { + executor: blocking, + afterRunPersist(run) { + persistedDeliveries.push( + ...run.deliveries + .filter((delivery) => delivery.state === "pending") + .map((delivery) => delivery.key), + ); + }, + }); + const command = startCommand({ + background: true, + idempotencyKey: "background-start", + }); + const published = await engine.execute(command); + assert.equal(published.background, true); + assert.equal(published.workspace.state, "creating"); + await blocking.started; + blocking.release(); + let completed = published; + for (let attempt = 0; attempt < 20; attempt++) { + completed = await engine.execute({ action: "inspect", runId: published.runId }); + if (completed.openGate?.kind === "questions") break; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + assert.equal(completed.openGate?.kind, "questions"); + assert.equal(completed.status, "waiting"); + for (let attempt = 0; attempt < 20; attempt++) { + if ((await setup.store.getStart(command.idempotencyKey))?.status === "completed") { + break; + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } + assert.equal( + (await setup.store.getStart(command.idempotencyKey))?.status, + "completed", + ); + assert.equal( + persistedDeliveries.some((key) => key.startsWith("gate:")), + true, + ); + assert.deepEqual(await engine.execute(command), completed); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +}); + +test("run concurrency may lower but never raise the configured ceiling", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await setup.engine().execute( + startCommand({ concurrency: 2, idempotencyKey: "concurrency-two" }), + ); + assert.equal( + (await setup.store.loadRun(started.runId)).effectiveConfig.concurrency.default, + 2, + ); + assert.equal(setup.executor.requests[0]?.runConcurrency, 2); + await assert.rejects( + setup.engine().execute( + startCommand({ + concurrency: DEFAULT_WORKFLOW_CONFIG.concurrency.ceiling + 1, + idempotencyKey: "concurrency-too-high", + featureName: "other-feature", + }), + ), + /cannot exceed ceiling/, + ); + }); +}); + +test("Questions and Plan Approval persist and resume across engine instances", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + setup.executor.enqueue("design", design("minor")); + setup.workspace.beforeCreate = async () => { + const [persisted] = await setup.store.listRuns(); + assert.equal(persisted?.workspace.state, "creating"); + assert.equal(persisted?.lease?.activeParentSessionId, "session-1"); + assert.equal(persisted?.assignments.length, 0); + }; + + const started = await setup.engine().execute(startCommand()); + assert.equal(started.status, "waiting"); + assert.equal(started.openGate?.kind, "questions"); + assert.equal(setup.workspace.createCalls, 1); + + const answered = await setup.engine().execute({ + action: "decide", + runId: started.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: started.revision, + decision: { + kind: "answer-questions", + answers: { "data-source": "Repository API" }, + }, + idempotencyKey: "answer-key", + }); + assert.equal(answered.status, "waiting"); + assert.equal(answered.openGate?.kind, "plan-approval"); + assert.equal(answered.uiImpact, "minor"); + assert.match( + await readFile(answered.planArtifact?.path ?? "missing", "utf8"), + /Implement the dashboard/, + ); + const designRequest = setup.executor.requests.find( + (request) => request.role === "design", + ); + assert.equal(designRequest?.decisions[0]?.value.kind, "answer-questions"); + + const inspected = await setup.engine().execute({ + action: "inspect", + runId: answered.runId, + }); + assert.deepEqual(inspected, answered); + + const approved = await setup.engine().execute({ + action: "decide", + runId: answered.runId, + gateId: answered.openGate?.id ?? "missing", + expectedRevision: answered.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-key", + }); + assert.equal(approved.status, "waiting"); + assert.equal(approved.openGate?.kind, "implementation-context"); + assert.equal(approved.implementationContext?.status, "waiting"); + assert.equal( + approved.stages.find((stage) => stage.kind === "design")?.status, + "completed", + ); + const approvedPlan = await readFile( + approved.planArtifact?.path ?? "missing", + "utf8", + ); + assert.match(approvedPlan, /Accepted Decisions/); + assert.match(approvedPlan, /Worktree/); + assert.match(approvedPlan, /UI Impact Evidence/); + assert.match(approvedPlan, /acceptedUiImpact/); + const persisted = await setup.store.loadRun(approved.runId); + assert.equal(persisted.decisions.length, 2); + assert.equal(persisted.deliveries.length, 3); + assert.deepEqual( + persisted.preflightEligibility.map((entry) => entry.role), + ["discover", "design", "implement", "verify", "review"], + ); + assert.ok( + persisted.preflightEligibility.every((entry) => + entry.candidates.some((candidate) => candidate.eligible), + ), + ); + }); +}); + +test("Implementation Context choices are persisted and Gate-bound", async () => { + for (const strategy of ["fresh-session", "current-session"] as const) { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + if (strategy === "current-session") enqueueSuccessfulPipeline(setup); + const planned = await setup.engine().execute(startCommand()); + const contextGate = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: `approve-${strategy}`, + }); + const selected = await setup.engine().execute({ + action: "decide", + runId: contextGate.runId, + gateId: contextGate.openGate?.id ?? "missing", + expectedRevision: contextGate.revision, + decision: { kind: "select-implementation-context", strategy }, + idempotencyKey: `context-${strategy}`, + }); + if (strategy === "fresh-session") { + assert.equal(selected.status, "waiting"); + assert.equal(selected.openGate?.kind, "implementation-context"); + assert.equal(selected.implementationContext?.status, "handoff-pending"); + } else { + assert.equal(selected.status, "completed"); + assert.equal(selected.openGate, undefined); + assert.equal(selected.implementationContext?.status, "ready"); + assert.equal(selected.implementationContext?.method, "unchanged"); + } + }); + } +}); + +test("Implementation Context rejects transitions while a child Assignment is active", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-active-context", + }); + const persisted = await setup.store.loadRun(approved.runId); + const queued = persisted.assignments.find( + (assignment) => assignment.role === "implement", + ); + assert.ok(queued); + queued.status = "running"; + queued.executionToken = "active-context-token"; + persisted.revision++; + await setup.store.saveRun(persisted, persisted.revision - 1); + await assert.rejects( + setup.engine().execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: persisted.revision, + decision: { + kind: "select-implementation-context", + strategy: "current-session", + }, + idempotencyKey: "select-active-context", + }), + /while an Assignment is active/, + ); + }); +}); + +test("fresh-session handoff is revision-bound and rotates the active parent only on consume", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + enqueueSuccessfulPipeline(setup); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-handoff", + }); + const selected = await setup.engine().execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "fresh-session", + }, + idempotencyKey: "select-handoff", + }); + const intent = selected.implementationContext; + assert.ok(intent); + const continued = await setup.engine().execute({ + action: "continue-context", + runId: selected.runId, + gateId: intent.gateId, + preparationId: intent.preparationId, + planRevision: intent.planRevision, + contextRevision: intent.runRevision, + expectedRevision: selected.revision, + sourceSessionFile: "/sessions/source.jsonl", + }); + const pending = continued.implementationContext; + assert.ok(pending); + const claimed = await setup.engine().execute({ + action: "claim-context-handoff", + runId: continued.runId, + gateId: pending.gateId, + preparationId: pending.preparationId, + planRevision: pending.planRevision, + contextRevision: pending.runRevision, + expectedRevision: continued.revision, + sourceSessionFile: "/sessions/source.jsonl", + targetSessionFile: "/sessions/target.jsonl", + reason: "new", + }); + assert.equal( + claimed.implementationContext?.status, + "claimed-target-awaiting-withSession", + ); + const claim = claimed.implementationContext; + assert.ok(claim); + await assert.rejects( + setup.engine("unrelated-session", { + processId: 200, + isProcessAlive: (processId) => processId === 100, + }).execute({ + action: "recover-context-intent", + runId: claimed.runId, + gateId: claim.gateId, + preparationId: claim.preparationId, + planRevision: claim.planRevision, + contextRevision: claim.runRevision, + expectedRevision: claimed.revision, + reason: "startup", + }), + /owned by live process 100/, + ); + assert.equal( + (await setup.store.loadRun(claimed.runId)).implementationContext?.status, + "claimed-target-awaiting-withSession", + ); + const consumed = await setup.engine("session-2").execute({ + action: "consume-context-handoff", + runId: claimed.runId, + gateId: claim.gateId, + preparationId: claim.preparationId, + planRevision: claim.planRevision, + contextRevision: claim.runRevision, + expectedRevision: claimed.revision, + targetSessionFile: "/sessions/target.jsonl", + previousSessionFile: "/sessions/source.jsonl", + }); + assert.equal(consumed.implementationContext?.status, "ready"); + assert.equal(consumed.implementationContext?.activeParentSessionId, "session-2"); + assert.equal(consumed.openGate, undefined); + await assert.rejects( + setup.engine("session-1").execute({ + action: "abort", + runId: consumed.runId, + expectedRevision: consumed.revision, + idempotencyKey: "historical-abort", + }), + /Historical parent sessions/, + ); + }); +}); + +test("smart compaction reports are fenced and unavailable alone enables fallback", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-smart", + }); + const selected = await setup.engine().execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "smart-compact", + }, + idempotencyKey: "select-smart", + }); + const preparation = selected.implementationContext; + assert.equal(preparation?.status, "smart-compact-pending"); + const unavailable = { + action: "decide" as const, + runId: selected.runId, + gateId: selected.openGate?.id ?? "missing", + expectedRevision: selected.revision, + decision: { + kind: "report-context-preparation" as const, + preparationId: preparation?.preparationId ?? "missing", + planRevision: preparation?.planRevision ?? -1, + method: "smart_compact" as const, + outcome: "unavailable" as const, + }, + idempotencyKey: "smart-unavailable", + }; + await assert.rejects(setup.engine().execute(unavailable), /activeToolNames/); + await assert.rejects( + setup.engine().execute({ + ...unavailable, + activeToolNames: ["smart_compact"], + }), + /smart_compact is active/, + ); + const fallback = await setup.engine().execute({ + ...unavailable, + activeToolNames: ["read", "grep"], + }); + assert.equal(fallback.status, "waiting"); + assert.equal(fallback.openGate?.kind, "implementation-context"); + assert.equal(fallback.implementationContext?.status, "normal-compact-pending"); + assert.equal(fallback.implementationContext?.outcome, "unavailable"); + const fallbackIntent = fallback.implementationContext; + assert.ok(fallbackIntent); + const compacting = await setup.engine().execute({ + action: "continue-context", + runId: fallback.runId, + gateId: fallbackIntent.gateId, + preparationId: fallbackIntent.preparationId, + planRevision: fallbackIntent.planRevision, + contextRevision: fallbackIntent.runRevision, + expectedRevision: fallback.revision, + }); + assert.equal(compacting.implementationContext?.status, "normal-compact-running"); + const compactIntent = compacting.implementationContext; + assert.ok(compactIntent); + const failed = await setup.engine().execute({ + action: "complete-normal-compaction", + runId: compacting.runId, + gateId: compactIntent.gateId, + preparationId: compactIntent.preparationId, + planRevision: compactIntent.planRevision, + contextRevision: compactIntent.runRevision, + expectedRevision: compacting.revision, + outcome: "failed", + error: "Native compaction failed", + }); + assert.equal(failed.status, "waiting"); + assert.equal(failed.openGate?.kind, "implementation-context"); + assert.equal(failed.implementationContext?.status, "failed"); + assert.equal(failed.implementationContext?.error, "Native compaction failed"); + }); +}); + +test("recovery marks an interrupted normal compaction intent failed", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-interrupted-compact", + }); + const selected = await setup.engine().execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "smart-compact", + }, + idempotencyKey: "select-interrupted-compact", + }); + const intent = selected.implementationContext; + assert.ok(intent); + const fallback = await setup.engine().execute({ + action: "decide", + runId: selected.runId, + gateId: intent.gateId, + expectedRevision: selected.revision, + decision: { + kind: "report-context-preparation", + preparationId: intent.preparationId, + planRevision: intent.planRevision, + method: "smart_compact", + outcome: "unavailable", + }, + idempotencyKey: "unavailable-interrupted-compact", + activeToolNames: ["read"], + }); + const fallbackIntent = fallback.implementationContext; + assert.ok(fallbackIntent); + await setup.engine().execute({ + action: "continue-context", + runId: fallback.runId, + gateId: fallbackIntent.gateId, + preparationId: fallbackIntent.preparationId, + planRevision: fallbackIntent.planRevision, + contextRevision: fallbackIntent.runRevision, + expectedRevision: fallback.revision, + }); + const recovered = await setup.engine("session-1", { + now: () => 100_000, + leaseStaleMs: 1, + processId: 200, + isProcessAlive: () => false, + }).recover(); + const view = recovered.find((candidate) => candidate.runId === fallback.runId); + assert.equal(view?.implementationContext?.status, "failed"); + assert.match(view?.implementationContext?.error ?? "", /interrupted/); + assert.equal(view?.openGate?.kind, "implementation-context"); + }); +}); + +test("smart compaction success, no-op, and failure outcomes remain distinct", async () => { + for (const outcome of ["succeeded", "no-op", "failed"] as const) { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + if (outcome !== "failed") enqueueSuccessfulPipeline(setup); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: `approve-smart-${outcome}`, + }); + const selected = await setup.engine().execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "smart-compact", + }, + idempotencyKey: `select-smart-${outcome}`, + }); + const intent = selected.implementationContext; + assert.ok(intent); + const decision: GateDecision = + outcome === "failed" + ? { + kind: "report-context-preparation", + preparationId: intent.preparationId, + planRevision: intent.planRevision, + method: "smart_compact", + outcome, + error: "smart_compact failed to prepare context", + } + : { + kind: "report-context-preparation", + preparationId: intent.preparationId, + planRevision: intent.planRevision, + method: "smart_compact", + outcome, + }; + const report = await setup.engine().execute({ + action: "decide", + runId: selected.runId, + gateId: intent.gateId, + expectedRevision: selected.revision, + decision, + idempotencyKey: `report-smart-${outcome}`, + activeToolNames: ["smart_compact"], + }); + assert.equal(report.implementationContext?.outcome, outcome); + assert.equal(report.implementationContext?.method, "smart_compact"); + if (outcome === "failed") { + assert.equal(report.status, "waiting"); + assert.equal(report.openGate?.kind, "implementation-context"); + assert.match(report.implementationContext?.error ?? "", /failed/); + } else { + assert.equal(report.status, "completed"); + assert.equal(report.openGate, undefined); + assert.equal(report.implementationContext?.status, "ready"); + } + }); + } +}); + +test("implementation completes only after host verification and two-axis review", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", { + ...design("minor"), + acceptanceCriteria: ["Search behavior works"], + verificationCommands: [ + { + executable: "npm", + args: ["test", "--", "feature"], + ownedPaths: ["src"], + kind: "test", + }, + ], + }); + enqueueSuccessfulPipeline(setup); + const hostCalls: string[] = []; + const hostCommands: HostCommandExecutor = { + async run(options) { + hostCalls.push(`${options.executable} ${options.args.join(" ")}`); + return { result: { code: 0, stdout: "passed", stderr: "" } }; + }, + }; + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-lifecycle", + }); + const completed = await setup.engine("session-1", { hostCommands }).execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "current-session", + }, + idempotencyKey: "context-lifecycle", + }); + assert.equal(completed.status, "completed"); + assert.deepEqual(hostCalls, ["npm test -- feature"]); + const persisted = await setup.store.loadRun(completed.runId); + assert.equal(persisted.verificationEvidence?.commands[0]?.status, "succeeded"); + assert.equal(persisted.verificationEvidence?.focusedBehavioralTests, true); + assert.equal(persisted.reviewVerdict?.specification.verdict, "approved"); + assert.equal(persisted.reviewVerdict?.standards.verdict, "approved"); + assert.deepEqual(persisted.mutatorProfileNames, ["feature-implementer"]); + assert.ok(persisted.reportArtifact); + assert.match( + await readFile(persisted.reportArtifact.path, "utf8"), + /Status: completed[\s\S]*Verification[\s\S]*Review/, + ); + assert.equal( + persisted.deliveries.some((delivery) => + delivery.key.startsWith(`terminal:${persisted.runId}:completed:`), + ), + true, + ); + const result = JSON.parse( + await readFile( + path.join(path.dirname(path.dirname(persisted.reportArtifact.path)), "result.json"), + "utf8", + ), + ) as { status?: string; revision?: number; snapshotRevision?: number }; + assert.equal(result.status, "completed"); + assert.equal(result.revision, persisted.reportArtifact.revision); + assert.equal(result.snapshotRevision, persisted.revision - 1); + const verifierRequest = setup.executor.requests.find( + (request) => request.role === "verify", + ); + const reviewerRequest = setup.executor.requests.find( + (request) => request.role === "review", + ); + assert.match(verifierRequest?.approvedPlan ?? "", /Acceptance Criteria/); + assert.equal( + verifierRequest?.verificationEvidence?.commands[0]?.status, + "succeeded", + ); + assert.match(reviewerRequest?.reviewDiff ?? "", /diff --git/); + assert.equal( + reviewerRequest?.verificationEvidence?.focusedBehavioralTests, + true, + ); + }); +}); + +test("recovery consumes a persisted successful mutator without replay", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-before-crash", + }); + const crashed = await setup.store.loadRun(approved.runId); + for (const gate of crashed.gates) gate.status = "satisfied"; + assert.ok(crashed.implementationContext); + crashed.implementationContext.status = "ready"; + crashed.implementationContext.strategy = "current-session"; + crashed.implementationContext.method = "unchanged"; + crashed.status = "running"; + const implement = crashed.assignments.find( + (assignment) => assignment.role === "implement", + ); + assert.ok(implement); + implement.status = "succeeded"; + implement.result = { + summary: "Mutation was durably completed before the crash", + changedPaths: ["src/index.ts"], + focusedTests: ["feature behavior"], + }; + implement.executionToken = undefined; + const implementStage = crashed.stages.find( + (candidate) => candidate.kind === "implement", + ); + assert.ok(implementStage); + implementStage.status = "running"; + crashed.lease = { + processId: 999, + activeParentSessionId: "session-1", + heartbeatAt: 0, + worktreeIdentity: crashed.workspace.root, + }; + crashed.revision++; + crashed.implementationContext.runRevision = crashed.revision; + await setup.store.saveRun(crashed, crashed.revision - 1); + + setup.executor.enqueue("verify", { + succeeded: true, + focusedBehavioralTests: true, + summary: "Recovered implementation verified", + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const [recovered] = await setup.engine("session-1", { + processId: 200, + now: () => 100_000, + isProcessAlive: () => false, + }).recover(); + assert.equal(recovered?.status, "completed"); + assert.equal( + setup.executor.requests.filter((request) => request.role === "implement") + .length, + 0, + ); + assert.equal( + (await setup.store.loadRun(crashed.runId)).assignments.find( + (assignment) => assignment.id === implement.id, + )?.status, + "succeeded", + ); + }); +}); + +test("Git or ownership safety failures open Recovery before remediation", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + setup.executor.enqueue("implement", { + summary: "Implementation", + changedPaths: ["src/index.ts"], + focusedTests: ["behavior"], + }); + const hostCommands: HostCommandExecutor = { + async run() { + throw new WorkspaceSafetyError("HEAD changed during verification"); + }, + }; + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-safety-recovery", + }); + const waiting = await setup.engine("session-1", { hostCommands }).execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "current-session", + }, + idempotencyKey: "context-safety-recovery", + }); + assert.equal(waiting.status, "waiting"); + assert.equal(waiting.openGate?.kind, "recovery"); + assert.equal( + waiting.assignments.some((assignment) => assignment.role === "remediate"), + false, + ); + }); +}); + +test("changes requested trigger serialized remediation, verification, and fresh review", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + setup.executor.enqueue("implement", { + summary: "Initial implementation", + changedPaths: ["src/index.ts"], + focusedTests: ["initial test"], + }); + setup.executor.enqueue("verify", { + succeeded: true, + focusedBehavioralTests: true, + summary: "Initial verification", + }); + setup.executor.enqueue("review", { + specification: { + verdict: "changes_requested", + findings: [{ severity: "concern", message: "Handle the edge case" }], + }, + standards: { verdict: "approved", findings: [] }, + }); + setup.executor.enqueue("remediate", { + summary: "Handled the edge case", + changedPaths: ["src/index.ts"], + focusedTests: ["edge case test"], + }); + setup.executor.enqueue("verify", { + succeeded: true, + focusedBehavioralTests: true, + summary: "Remediation verified", + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const planned = await setup.engine().execute(startCommand()); + const approved = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "approve-remediation", + }); + const completed = await setup.engine().execute({ + action: "decide", + runId: approved.runId, + gateId: approved.openGate?.id ?? "missing", + expectedRevision: approved.revision, + decision: { + kind: "select-implementation-context", + strategy: "current-session", + }, + idempotencyKey: "context-remediation", + }); + assert.equal(completed.status, "completed"); + const persisted = await setup.store.loadRun(completed.runId); + assert.equal( + persisted.assignments.filter((assignment) => assignment.role === "remediate") + .length, + 1, + ); + assert.equal( + persisted.assignments.filter( + (assignment) => assignment.role === "review" && assignment.status === "succeeded", + ).length, + 2, + ); + assert.equal(persisted.reviewCycles, 2); + assert.equal(persisted.verificationCycles, 0); + const reviewerNames = persisted.assignments + .filter((assignment) => assignment.role === "review") + .map((assignment) => assignment.profileName); + assert.ok(reviewerNames.every((name) => !persisted.mutatorProfileNames.includes(name))); + }); +}); + +test("non-trivial UI requires three prototypes, acceptance, and cleanup", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + questions: [], + candidatePaths: ["src/routes/layout.svelte"], + languages: ["svelte"], + tags: ["frontend", "responsive"], + riskTags: ["accessibility"], + }); + setup.executor.enqueue("design", design("non-trivial")); + for (let index = 1; index <= 3; index++) { + setup.executor.enqueue("prototype", { + path: `prototypes/candidate-${index}`, + route: `/prototype?variant=${index}`, + structuralRationale: `Distinct structure ${index}`, + responsiveChecks: ["mobile 390px", "desktop 1440px"], + browserEvidence: [`browser screenshot ${index}`], + launchInstructions: `npm run dev then open variant ${index}`, + }); + } + const planned = await setup.engine().execute(startCommand()); + const prototypes = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "non-trivial", + }, + idempotencyKey: "approve-prototype-plan", + }); + assert.equal(prototypes.openGate?.kind, "prototype-acceptance"); + const persistedCandidates = ( + await setup.store.loadRun(prototypes.runId) + ).prototypeCandidates; + assert.equal(persistedCandidates.length, 3); + assert.equal( + new Set(persistedCandidates.map((candidate) => candidate.path)).size, + 3, + ); + assert.ok( + persistedCandidates.every((candidate) => + candidate.artifactPath.includes("artifacts/prototypes"), + ), + ); + await assert.rejects( + setup.engine().execute({ + action: "decide", + runId: prototypes.runId, + gateId: prototypes.openGate?.id ?? "missing", + expectedRevision: prototypes.revision, + decision: { + kind: "accept-prototype", + candidateId: persistedCandidates[0]?.id ?? "missing", + candidateRevision: (persistedCandidates[0]?.revision ?? 0) + 1, + browserReviewed: true, + }, + idempotencyKey: "stale-prototype", + }), + /candidate or revision is stale/, + ); + const acceptedCandidate = persistedCandidates[1]!; + const accepted = await setup.engine().execute({ + action: "decide", + runId: prototypes.runId, + gateId: prototypes.openGate?.id ?? "missing", + expectedRevision: prototypes.revision, + decision: { + kind: "accept-prototype", + candidateId: acceptedCandidate.id, + candidateRevision: acceptedCandidate.revision, + browserReviewed: true, + feedback: "Use the second navigation structure", + }, + idempotencyKey: "accept-prototype", + }); + assert.equal(accepted.openGate?.kind, "implementation-context"); + assert.match( + await readFile(accepted.planArtifact?.path ?? "missing", "utf8"), + /Accepted Prototype[\s\S]*second navigation structure/, + ); + + setup.executor.enqueue("implement", { + summary: "Implemented accepted direction to production standards", + changedPaths: ["src/routes/layout.svelte"], + focusedTests: ["responsive navigation"], + }); + setup.executor.enqueue("remediate", { + summary: "Removed prototype-only candidates and routes", + changedPaths: ["prototypes"], + focusedTests: [], + }); + setup.executor.enqueue("verify", { + succeeded: true, + focusedBehavioralTests: true, + summary: "Responsive browser behavior verified", + }); + setup.executor.enqueue("review", { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }); + const completed = await setup.engine().execute({ + action: "decide", + runId: accepted.runId, + gateId: accepted.openGate?.id ?? "missing", + expectedRevision: accepted.revision, + decision: { + kind: "select-implementation-context", + strategy: "current-session", + }, + idempotencyKey: "prototype-context", + }); + assert.equal(completed.status, "completed"); + assert.equal( + (await setup.store.loadRun(completed.runId)).prototypeCleanupCompleted, + true, + ); + }); +}); + +test("prototype refinement produces a fresh three-candidate revision", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + questions: [], + candidatePaths: ["src/routes/layout.svelte"], + languages: ["svelte"], + tags: ["frontend", "responsive"], + riskTags: [], + }); + setup.executor.enqueue("design", design("non-trivial")); + for (let index = 1; index <= 3; index++) { + setup.executor.enqueue("prototype", { + path: `prototypes/candidate-${index}`, + route: `/first?variant=${index}`, + structuralRationale: `Initial structure ${index}`, + responsiveChecks: ["mobile and desktop"], + browserEvidence: [`initial browser evidence ${index}`], + launchInstructions: "npm run dev", + }); + } + const planned = await setup.engine().execute(startCommand()); + assert.equal(planned.status, "waiting", planned.error?.message); + assert.equal(planned.openGate?.kind, "plan-approval"); + const firstGate = await setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "non-trivial", + }, + idempotencyKey: "approve-refinement-plan", + }); + const refinementRevision = firstGate.revision + 1; + for (let index = 1; index <= 3; index++) { + setup.executor.enqueue("prototype", { + path: `prototypes/refinement-${refinementRevision}-candidate-${index}`, + route: `/refined?variant=${index}`, + structuralRationale: `Refined structure ${index}`, + responsiveChecks: ["mobile and desktop"], + browserEvidence: [`refined browser evidence ${index}`], + launchInstructions: "npm run dev", + }); + } + const refined = await setup.engine().execute({ + action: "decide", + runId: firstGate.runId, + gateId: firstGate.openGate?.id ?? "missing", + expectedRevision: firstGate.revision, + decision: { + kind: "refine-prototypes", + feedback: "Increase information density", + }, + idempotencyKey: "refine-prototypes", + }); + assert.equal(refined.openGate?.kind, "prototype-acceptance"); + const persisted = await setup.store.loadRun(refined.runId); + assert.equal(persisted.prototypeCandidates.length, 6); + assert.equal( + persisted.gates.find((gate) => gate.id === firstGate.openGate?.id)?.status, + "rejected", + ); + }); +}); + +test("objective UI evidence upgrades a planner downgrade before approval", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + questions: [], + candidatePaths: ["src/routes/layout.svelte"], + languages: ["svelte"], + tags: ["frontend"], + riskTags: ["accessibility"], + }); + const downgraded = design("minor"); + downgraded.assignments.push({ + role: "prototype", + profileName: "ui-prototyper", + rationale: "Prototype the objective non-trivial UI change", + mutates: true, + ownedPaths: ["prototypes"], + }); + setup.executor.enqueue("design", downgraded); + const planned = await setup.engine().execute( + startCommand({ request: "Redesign the responsive navigation" }), + ); + assert.equal(planned.uiImpact, "non-trivial"); + assert.ok( + planned.uiImpactEvidence.some((evidence) => + evidence.includes("responsive"), + ), + ); + await assert.rejects( + setup.engine().execute({ + action: "decide", + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "minor", + }, + idempotencyKey: "reject-ui-downgrade", + }), + /Approved UI impact does not match the current plan/, + ); + }); +}); + +test("concurrent Plan Approval cannot cross-link immutable plan content", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design("minor")); + const planned = await setup.engine().execute(startCommand()); + const command = (key: string, comment: string) => ({ + action: "decide" as const, + runId: planned.runId, + gateId: planned.openGate?.id ?? "missing", + expectedRevision: planned.revision, + decision: { + kind: "approve-plan" as const, + approved: true as const, + acceptedUiImpact: "minor" as const, + comment, + }, + idempotencyKey: key, + }); + const results = await Promise.allSettled([ + setup.engine().execute(command("approve-a", "approval A")), + setup.engine().execute(command("approve-b", "approval B")), + ]); + assert.equal( + results.filter((result) => result.status === "fulfilled").length, + 1, + ); + const persisted = await setup.store.loadRun(planned.runId); + assert.ok(persisted.planArtifact); + const contents = await readFile(persisted.planArtifact.path, "utf8"); + assert.equal( + createHash("sha256").update(contents).digest("hex"), + persisted.planArtifact.digest, + ); + const accepted = persisted.decisions.find( + (decision) => decision.value.kind === "approve-plan", + ); + assert.ok(accepted?.value.kind === "approve-plan"); + assert.match(contents, new RegExp(accepted.value.comment ?? "missing")); + }); +}); + +test("plan revision runs a fresh Design Assignment with persisted feedback", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design()); + setup.executor.enqueue("design", { + ...design(), + plan: "# Revised plan\n\nApply the requested changes.", + }); + const initial = await setup.engine().execute(startCommand()); + assert.equal(initial.openGate?.kind, "plan-approval"); + const revised = await setup.engine().execute({ + action: "decide", + runId: initial.runId, + gateId: initial.openGate?.id ?? "missing", + expectedRevision: initial.revision, + decision: { kind: "revise-plan", feedback: "Keep the API smaller" }, + idempotencyKey: "revise-plan", + }); + assert.equal(revised.openGate?.kind, "plan-approval"); + assert.notEqual(revised.openGate?.id, initial.openGate?.id); + assert.equal( + revised.assignments.filter( + (assignment) => + assignment.role === "design" && assignment.status === "succeeded", + ).length, + 2, + ); + const latestDesignRequest = setup.executor.requests + .filter((request) => request.role === "design") + .at(-1); + assert.deepEqual(latestDesignRequest?.decisions.at(-1)?.value, { + kind: "revise-plan", + feedback: "Keep the API smaller", + }); + }); +}); + +test("recovery reuses a revised Design result persisted before gate publication", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", design()); + setup.executor.enqueue("design", { + ...design(), + plan: "# Revised durable plan", + }); + const initial = await setup.engine().execute(startCommand()); + const command = { + action: "decide" as const, + runId: initial.runId, + gateId: initial.openGate?.id ?? "missing", + expectedRevision: initial.revision, + decision: { + kind: "revise-plan" as const, + feedback: "Use the smaller design", + }, + idempotencyKey: "durable-revision", + }; + await assert.rejects( + setup.engine("session-1", { + afterAssignmentPersist(assignment) { + if (assignment.role === "design") { + throw new Error("crash before revised gate"); + } + }, + }).execute(command), + /crash before revised gate/, + ); + const resumed = await setup.engine().execute(command); + assert.equal(resumed.openGate?.kind, "plan-approval"); + assert.equal( + setup.executor.requests.filter((request) => request.role === "design").length, + 2, + ); + }); +}); + +test("planner selections are eligibility-checked and persisted as queued Assignments", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const profileFor = (role: "implement" | "verify" | "review") => { + const profile = setup.profiles.find((candidate) => candidate.roles.includes(role)); + assert.ok(profile); + return profile; + }; + const implementer = profileFor("implement"); + const verifier = profileFor("verify"); + const reviewer = profileFor("review"); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", { + ...design(), + assignments: [ + { + role: "implement", + profileName: implementer.name, + rationale: "Own the implementation diff", + mutates: true, + ownedPaths: ["src"], + }, + { + role: "verify", + profileName: verifier.name, + rationale: "Run focused verification", + mutates: false, + ownedPaths: [], + }, + { + role: "review", + profileName: reviewer.name, + rationale: "Review independently", + mutates: false, + ownedPaths: [], + }, + ], + }); + + const planned = await setup.engine().execute(startCommand()); + assert.equal(planned.openGate?.kind, "plan-approval"); + const queued = planned.assignments.filter((assignment) => + ["implement", "verify", "review"].includes(assignment.role), + ); + assert.deepEqual( + queued.map((assignment) => assignment.role), + ["implement", "verify", "review"], + ); + assert.ok( + queued.every((assignment) => + assignment.selection.eligibleProfiles.some( + (candidate) => + candidate.profileName === assignment.profileName && candidate.eligible, + ), + ), + ); + assert.notEqual( + queued.find((assignment) => assignment.role === "implement")?.profileName, + queued.find((assignment) => assignment.role === "review")?.profileName, + ); + }); +}); + +test("planner output fails closed when required Assignments are missing", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", { + ...design(), + assignments: [], + }); + const failed = await setup.engine().execute(startCommand()); + assert.equal(failed.status, "failed"); + assert.match(failed.error?.message ?? "", /exactly one implement Assignment/); + }); +}); + +test("planner output fails closed on an ineligible profile", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const ineligible = setup.profiles.find((profile) => + profile.roles.includes("discover"), + ); + const verifier = setup.profiles.find((profile) => profile.roles.includes("verify")); + const reviewer = setup.profiles.find((profile) => profile.roles.includes("review")); + assert.ok(ineligible && verifier && reviewer); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: [], + }); + setup.executor.enqueue("design", { + ...design(), + assignments: [ + { + role: "implement", + profileName: ineligible.name, + rationale: "Invalid selection", + mutates: true, + ownedPaths: ["src"], + }, + { + role: "verify", + profileName: verifier.name, + rationale: "Verify", + mutates: false, + ownedPaths: [], + }, + { + role: "review", + profileName: reviewer.name, + rationale: "Review", + mutates: false, + ownedPaths: [], + }, + ], + }); + const failed = await setup.engine().execute(startCommand()); + assert.equal(failed.status, "failed"); + assert.match(failed.error?.message ?? "", /ineligible implement profile/); + }); +}); + +test("production execution metadata and transcripts are persisted", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const executor: AssignmentExecutor = { + async run() { + return { + kind: "pi-workflow-assignment-result" as const, + output: discoveryWithQuestions(), + metadata: { + childSessionId: "child-session-1", + provider: "provider-a", + model: "model-a", + effort: "high", + contextCapacity: 128_000, + transcript: [{ role: "assistant", text: "Discovery complete" }], + }, + }; + }, + }; + const started = await setup.engine("session-1", { executor }).execute(startCommand()); + const assignment = started.assignments.find( + (candidate) => candidate.role === "discover", + ); + assert.equal(assignment?.childSessionId, "child-session-1"); + assert.equal(assignment?.effectiveProvider, "provider-a"); + assert.equal(assignment?.effectiveModel, "model-a"); + assert.equal(assignment?.effectiveEffort, "high"); + assert.equal(assignment?.contextCapacity, 128_000); + assert.match( + await readFile(assignment?.transcriptArtifact?.path ?? "missing", "utf8"), + /Discovery complete/, + ); + }); +}); + +test("capacity reservation precedes the persisted running transition", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const observed: WorkflowAssignment["status"][] = []; + const executor: AssignmentExecutor = { + reservesCapacity: true, + async run(request) { + const before = await setup.store.loadRun(request.runId); + observed.push( + before.assignments.find((assignment) => assignment.role === request.role) + ?.status ?? "failed", + ); + await request.onReserved?.(); + const after = await setup.store.loadRun(request.runId); + observed.push( + after.assignments.find((assignment) => assignment.role === request.role) + ?.status ?? "failed", + ); + return discoveryWithQuestions(); + }, + }; + await setup.engine("session-1", { executor }).execute(startCommand()); + assert.deepEqual(observed, ["queued", "running"]); + }); +}); + +test("failed execution metadata and transcripts are persisted", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const executor: AssignmentExecutor = { + async run() { + throw Object.assign(new Error("provider failed"), { + assignmentMetadata: { + childSessionId: "child-session-failed", + provider: "provider-a", + model: "model-a", + effort: "low", + contextCapacity: 32_000, + transcript: [{ role: "assistant", text: "partial response" }], + }, + }); + }, + }; + const failed = await setup.engine("session-1", { executor }).execute(startCommand()); + const assignment = failed.assignments.find( + (candidate) => candidate.role === "discover", + ); + assert.equal(failed.status, "failed"); + assert.equal(assignment?.status, "failed"); + assert.equal(assignment?.childSessionId, "child-session-failed"); + assert.match( + await readFile(assignment?.transcriptArtifact?.path ?? "missing", "utf8"), + /partial response/, + ); + }); +}); + +test("start and decision idempotency prevent replay and reject changed commands", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + setup.executor.enqueue("design", design()); + const engine = setup.engine(); + const command = startCommand(); + const first = await engine.execute(command); + const repeated = await engine.execute(command); + assert.deepEqual(repeated, first); + assert.equal(setup.workspace.prepareCalls, 1); + assert.equal(setup.workspace.createCalls, 1); + assert.equal(setup.executor.requests.length, 1); + + const decide = { + action: "decide" as const, + runId: first.runId, + gateId: first.openGate?.id ?? "missing", + expectedRevision: first.revision, + decision: { + kind: "answer-questions" as const, + answers: { "data-source": "Repository API" }, + }, + idempotencyKey: "answer-key", + }; + const decided = await engine.execute(decide); + assert.deepEqual(await engine.execute(decide), decided); + assert.equal( + setup.executor.requests.filter((request) => request.role === "design") + .length, + 1, + ); + await assert.rejects( + engine.execute({ + ...decide, + decision: { + kind: "answer-questions", + answers: { "data-source": "Different answer" }, + }, + }), + /another command/, + ); + }); +}); + +test("concurrent starts share one authoritative run and workspace mutation", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const blocking = new BlockingExecutor(discoveryWithQuestions()); + const first = setup.engine("session-1", { executor: blocking }); + const second = setup.engine("session-1", { executor: blocking }); + const firstStart = first.execute(startCommand()); + await blocking.started; + const secondStart = second.execute(startCommand()); + assert.equal(setup.workspace.prepareCalls, 1); + assert.equal(setup.workspace.createCalls, 1); + blocking.release(); + const [firstResult, secondResult] = await Promise.all([ + firstStart, + secondStart, + ]); + assert.deepEqual(secondResult, firstResult); + assert.equal((await setup.store.listRuns()).length, 1); + assert.equal(blocking.requests.length, 1); + }); +}); + +test("cross-process starts claim the published run before workspace mutation", async () => { + await withTempDir(async (directory) => { + const state = path.join(directory, "state"); + const stateAlias = path.join(directory, "state-alias"); + await mkdir(state); + await symlink(state, stateAlias); + const profiles = ( + await loadAgentProfiles({ cwd: directory, projectTrusted: false }) + ).profiles; + const workspace = new FakeWorkspace(); + const executor = new BlockingExecutor(discoveryWithQuestions()); + let leftId = 0; + let rightId = 0; + const makeEngine = ( + store: WorkflowStore, + processId: number, + createId: () => string, + ) => + new WorkflowEngine({ + store, + workspace, + executor, + sourceRoot: directory, + parentSessionId: "session-1", + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + profiles, + now: () => 10_000, + createId, + processId, + isProcessAlive: () => true, + }); + const left = makeEngine( + new WorkflowStore(state), + 100, + () => `left-${++leftId}`, + ); + const right = makeEngine( + new WorkflowStore(stateAlias), + 200, + () => `right-${++rightId}`, + ); + + const leftStart = left.execute(startCommand()); + const rightStart = right.execute(startCommand()); + await executor.started; + assert.equal(workspace.createCalls, 1); + executor.release(); + const [leftResult, rightResult] = await Promise.all([ + leftStart, + rightStart, + ]); + assert.equal(leftResult.runId, rightResult.runId); + assert.equal(workspace.createCalls, 1); + assert.equal(executor.requests.length, 1); + assert.equal((await new WorkflowStore(state).listRuns()).length, 1); + }); +}); + +test("live workspace creation cannot be stolen after its heartbeat ages", async () => { + await withTempDir(async (directory) => { + const state = path.join(directory, "state"); + const stateAlias = path.join(directory, "state-alias"); + await mkdir(state); + await symlink(state, stateAlias); + const profiles = ( + await loadAgentProfiles({ cwd: directory, projectTrusted: false }) + ).profiles; + const workspace = new FakeWorkspace(); + let creationStarted: (() => void) | undefined; + let releaseCreation: (() => void) | undefined; + const startedCreating = new Promise((resolve) => { + creationStarted = resolve; + }); + const creationWait = new Promise((resolve) => { + releaseCreation = resolve; + }); + workspace.beforeCreate = async () => { + creationStarted?.(); + await creationWait; + }; + const executor = new ScriptedExecutor(); + executor.enqueue("discover", discoveryWithQuestions()); + let now = 10_000; + let leftId = 0; + let rightId = 0; + const makeEngine = ( + store: WorkflowStore, + processId: number, + createId: () => string, + ) => + new WorkflowEngine({ + store, + workspace, + executor, + sourceRoot: directory, + parentSessionId: "session-1", + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + profiles, + now: () => now, + createId, + processId, + leaseStaleMs: 10, + isProcessAlive: () => true, + }); + const owner = makeEngine( + new WorkflowStore(state), + 100, + () => `owner-${++leftId}`, + ); + const contender = makeEngine( + new WorkflowStore(stateAlias), + 200, + () => `contender-${++rightId}`, + ); + + const ownerStart = owner.execute(startCommand()); + await startedCreating; + now = 100_000; + const contenderResult = await contender.execute(startCommand()); + assert.equal(contenderResult.workspace.state, "creating"); + assert.equal(workspace.createCalls, 1); + assert.deepEqual(await contender.recover(), []); + assert.equal(workspace.reconcileCalls, 0); + releaseCreation?.(); + const completed = await ownerStart; + assert.equal(completed.openGate?.kind, "questions"); + assert.equal(workspace.createCalls, 1); + assert.equal(executor.requests.length, 1); + const startRecord = await new WorkflowStore(state).getStart("start-key"); + assert.equal( + (startRecord?.result as { revision?: number } | undefined)?.revision, + completed.revision, + ); + }); +}); + +test("a pending start resumes after interrupted initial publication", async () => { + await withTempDir(async (directory) => { + let interrupt = true; + const setup = await fixture(directory, { + beforeRename(target) { + if (interrupt && target.endsWith("request.md")) { + throw new Error("simulated initial publication crash"); + } + }, + }); + setup.executor.enqueue("discover", discoveryWithQuestions()); + await assert.rejects( + setup.engine().execute(startCommand()), + /simulated initial publication crash/, + ); + + interrupt = false; + const resumed = await setup.engine().execute(startCommand()); + assert.equal(resumed.status, "waiting"); + assert.equal(resumed.openGate?.kind, "questions"); + assert.equal(setup.workspace.prepareCalls, 2); + assert.equal(setup.workspace.createCalls, 1); + assert.equal((await setup.store.listRuns()).length, 1); + }); +}); + +test("a durable ready revision survives convenience-snapshot interruption", async () => { + await withTempDir(async (directory) => { + let workflowWrites = 0; + let interrupt = true; + const setup = await fixture(directory, { + beforeRename(target) { + if (target.endsWith("workflow.json")) { + workflowWrites++; + if (interrupt && workflowWrites === 2) { + throw new Error("simulated ready snapshot crash"); + } + } + }, + }); + setup.executor.enqueue("discover", discoveryWithQuestions()); + await assert.rejects( + setup.engine().execute(startCommand()), + /simulated ready snapshot crash/, + ); + assert.equal(setup.workspace.createCalls, 1); + + interrupt = false; + const resumed = await setup.engine().execute(startCommand()); + assert.equal(resumed.workspace.state, "ready"); + assert.equal(resumed.openGate?.kind, "questions"); + assert.equal(setup.workspace.createCalls, 1); + assert.equal(setup.workspace.reconcileCalls, 0); + }); +}); + +test("real Git persisted readiness precedes retry preparation", async () => { + await withTempDir(async (directory) => { + const repository = path.join(directory, "repository"); + await mkdir(repository); + const git = (args: string[], cwd = repository) => + execFileAsync("git", args, { cwd, encoding: "utf8" }); + await git(["init", "-b", "main"]); + await git(["config", "user.email", "pi-workflow@example.invalid"]); + await git(["config", "user.name", "Pi Workflow Test"]); + await writeFile( + path.join(repository, ".gitignore"), + ".worktrees/\n", + "utf8", + ); + await writeFile(path.join(repository, "README.md"), "# Fixture\n", "utf8"); + await git(["add", ".gitignore", "README.md"]); + await git(["commit", "-m", "initial fixture"]); + + let workflowWrites = 0; + let interrupt = true; + const store = new WorkflowStore(path.join(directory, "state"), { + beforeRename(target) { + if (target.endsWith("workflow.json")) { + workflowWrites++; + if (interrupt && workflowWrites === 2) { + throw new Error("simulated real ready snapshot crash"); + } + } + }, + }); + const executor = new ScriptedExecutor(); + executor.enqueue("discover", discoveryWithQuestions()); + const profiles = ( + await loadAgentProfiles({ cwd: repository, projectTrusted: false }) + ).profiles; + let id = 0; + const makeEngine = () => + new WorkflowEngine({ + store, + workspace: new GitWorkspaceManager(), + executor, + sourceRoot: repository, + parentSessionId: "session-1", + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + profiles, + now: () => 10_000, + createId: () => `real-id-${++id}`, + processId: 100, + isProcessAlive: () => true, + }); + + await assert.rejects( + makeEngine().execute(startCommand()), + /simulated real ready snapshot crash/, + ); + interrupt = false; + const resumed = await makeEngine().execute(startCommand()); + assert.equal(resumed.workspace.state, "ready"); + assert.equal(resumed.openGate?.kind, "questions"); + assert.equal(executor.requests.length, 1); + const sourceStatus = await git(["status", "--porcelain"]); + assert.equal(sourceStatus.stdout, ""); + }); +}); + +test("persisted discovery output resumes during recovery without replay", async () => { + await withTempDir(async (directory) => { + let interrupt = true; + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const crashing = setup.engine("session-1", { + afterAssignmentPersist(assignment) { + if (interrupt && assignment.role === "discover") { + interrupt = false; + throw new Error("simulated post-assignment crash"); + } + }, + }); + await assert.rejects( + crashing.execute(startCommand()), + /simulated post-assignment crash/, + ); + const [persisted] = await setup.store.listRuns(); + assert.equal(persisted.assignments[0]?.status, "succeeded"); + if (persisted.lease) { + persisted.lease.processId = 999; + persisted.lease.heartbeatAt = 0; + } + persisted.revision++; + await setup.store.saveRun(persisted); + + const recovery = setup.engine("session-2", { + now: () => 100_000, + processId: 200, + isProcessAlive: () => false, + }); + const [resumed] = await recovery.recover(); + assert.equal(resumed?.openGate?.kind, "questions"); + assert.equal(setup.executor.requests.length, 1); + const replayedStart = await recovery.execute(startCommand()); + assert.equal(replayedStart.openGate?.kind, "questions"); + assert.equal(setup.executor.requests.length, 1); + }); +}); + +test("pending decision continuation consumes persisted design output once", async () => { + await withTempDir(async (directory) => { + let interrupt = true; + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + setup.executor.enqueue("design", design()); + const crashing = setup.engine("session-1", { + afterAssignmentPersist(assignment) { + if (interrupt && assignment.role === "design") { + interrupt = false; + throw new Error("simulated decision continuation crash"); + } + }, + }); + const started = await crashing.execute(startCommand()); + const command = { + action: "decide" as const, + runId: started.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: started.revision, + decision: { + kind: "answer-questions" as const, + answers: { "data-source": "Repository API" }, + }, + idempotencyKey: "answer-key", + }; + await assert.rejects( + crashing.execute(command), + /simulated decision continuation crash/, + ); + const interrupted = await setup.store.loadRun(started.runId); + assert.equal( + interrupted.commandReceipts.find( + (receipt) => receipt.key === "answer-key", + )?.status, + "pending", + ); + assert.equal( + interrupted.assignments.find((assignment) => assignment.role === "design") + ?.status, + "succeeded", + ); + + if (interrupted.lease) { + interrupted.lease.processId = 999; + interrupted.lease.heartbeatAt = 0; + } + interrupted.revision++; + await setup.store.saveRun(interrupted); + const recovery = setup.engine("session-2", { + now: () => 100_000, + processId: 200, + isProcessAlive: () => false, + }); + const [resumed] = await recovery.recover(); + assert.equal(resumed?.openGate?.kind, "plan-approval"); + assert.equal( + (await recovery.execute(command)).openGate?.kind, + "plan-approval", + ); + assert.equal( + setup.executor.requests.filter((request) => request.role === "design") + .length, + 1, + ); + assert.equal( + (await setup.store.loadRun(started.runId)).commandReceipts.find( + (receipt) => receipt.key === "answer-key", + )?.status, + "completed", + ); + }); +}); + +test("a concurrent duplicate decision cannot complete an active continuation", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await setup.engine().execute(startCommand()); + const blocking = new BlockingExecutor(design()); + const engine = setup.engine("session-1", { + executor: blocking, + assignmentHeartbeatMs: 2, + }); + const command = { + action: "decide" as const, + runId: started.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: started.revision, + decision: { + kind: "answer-questions" as const, + answers: { "data-source": "Repository API" }, + }, + idempotencyKey: "answer-key", + }; + const deciding = engine.execute(command); + await blocking.started; + const duplicate = await engine.execute(command); + assert.equal(duplicate.status, "running"); + assert.equal( + (await setup.store.loadRun(started.runId)).commandReceipts.find( + (receipt) => receipt.key === "answer-key", + )?.status, + "pending", + ); + blocking.release(); + assert.equal((await deciding).openGate?.kind, "plan-approval"); + }); +}); + +test("active assignment leases heartbeat and block concurrent recovery", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const blocking = new BlockingExecutor(discoveryWithQuestions()); + let now = 10_000; + const engine = setup.engine("session-1", { + executor: blocking, + now: () => now, + processId: 100, + leaseStaleMs: 10, + assignmentHeartbeatMs: 2, + }); + const start = engine.execute(startCommand()); + await blocking.started; + now = 100_000; + await new Promise((resolve) => setTimeout(resolve, 10)); + const [running] = await setup.store.listRuns(); + const assignment = running.assignments[0]; + assert.equal(assignment?.status, "running"); + const lease = await setup.store.loadAssignmentLease( + running.runId, + assignment?.id ?? "missing", + ); + assert.equal(lease?.heartbeatAt, now); + assert.deepEqual( + await setup + .engine("session-2", { + now: () => now, + processId: 100, + leaseStaleMs: 10, + isProcessAlive: () => false, + }) + .recover(), + [], + ); + blocking.release(); + assert.equal((await start).openGate?.kind, "questions"); + }); +}); + +test("a stale heartbeat fences a live read-only assignment during recovery", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const blocking = new BlockingExecutor(discoveryWithQuestions()); + let now = 10_000; + const start = setup + .engine("session-1", { + executor: blocking, + now: () => now, + processId: 100, + leaseStaleMs: 10, + assignmentHeartbeatMs: 1_000, + }) + .execute(startCommand()); + const startOutcome = start.then( + () => undefined, + (error: unknown) => error, + ); + await blocking.started; + now = 100_000; + const recovery = setup.engine("session-2", { + now: () => now, + processId: 200, + leaseStaleMs: 10, + isProcessAlive: () => true, + }); + const [recovered] = await recovery.recover(); + assert.equal(recovered?.openGate?.kind, "recovery"); + assert.equal(recovered?.assignments[0]?.status, "interrupted"); + blocking.release(); + assert.match(String(await startOutcome), /lost its execution lease/); + }); +}); + +test("a live mutating assignment is not reclaimed on heartbeat age alone", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await setup.engine().execute(startCommand()); + const run = await setup.store.loadRun(started.runId); + const assignment = run.assignments[0]; + assert.ok(assignment); + assignment.status = "running"; + assignment.mutates = true; + assignment.executionToken = "live-mutating-token"; + for (const gate of run.gates) gate.status = "satisfied"; + run.status = "running"; + if (run.lease) { + run.lease.processId = 100; + run.lease.heartbeatAt = 0; + } + run.revision++; + await setup.store.saveRun(run); + await setup.store.createAssignmentLease(run.runId, assignment.id, { + token: assignment.executionToken, + processId: 100, + heartbeatAt: 0, + }); + + const recovery = setup.engine("session-2", { + now: () => 100_000, + processId: 200, + leaseStaleMs: 10, + isProcessAlive: () => true, + }); + assert.deepEqual(await recovery.recover(), []); + assert.equal( + (await setup.store.loadRun(run.runId)).assignments[0]?.status, + "running", + ); + }); +}); + +test("an executor cannot persist after losing its assignment fence", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const blocking = new BlockingExecutor(discoveryWithQuestions()); + const start = setup + .engine("session-1", { + executor: blocking, + assignmentHeartbeatMs: 1_000, + }) + .execute(startCommand()); + await blocking.started; + const [running] = await setup.store.listRuns(); + const assignment = running.assignments[0]; + assert.ok(assignment?.executionToken); + await setup.store.releaseAssignmentLease( + running.runId, + assignment.id, + assignment.executionToken, + ); + blocking.release(); + await assert.rejects(start, /lost its execution lease/); + assert.equal( + (await setup.store.loadRun(running.runId)).assignments[0]?.status, + "running", + ); + }); +}); + +test("runtime command schemas reject malformed and open shapes", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const engine = setup.engine(); + const malformed: unknown[] = [ + { + ...startCommand(), + unexpected: true, + }, + { + ...startCommand(), + background: "yes", + }, + { + action: "inspect", + runId: "run-1", + extra: true, + }, + { + action: "decide", + runId: "run-1", + gateId: "gate-1", + expectedRevision: 0, + decision: { + kind: "approve-plan", + approved: false, + acceptedUiImpact: "none", + }, + idempotencyKey: "decision-key", + }, + { + action: "decide", + runId: "run-1", + gateId: "gate-1", + expectedRevision: 0, + decision: { kind: "recover", resolution: "continue" }, + idempotencyKey: "decision-key", + }, + ]; + for (const command of malformed) { + await assert.rejects(engine.execute(command as WorkflowCommand)); + } + assert.equal(setup.workspace.prepareCalls, 0); + }); +}); + +test("decisions require the single active Gate and its waiting stage", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const engine = setup.engine(); + const started = await engine.execute(startCommand()); + const run = await setup.store.loadRun(started.runId); + run.gates.push({ + id: "second-open-gate", + kind: "recovery", + status: "open", + revision: run.revision, + openedAt: 10_000, + }); + run.revision++; + await setup.store.saveRun(run); + await assert.rejects( + engine.execute({ + action: "decide", + runId: run.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: run.revision, + decision: { + kind: "answer-questions", + answers: { "data-source": "Repository API" }, + }, + idempotencyKey: "multiple-gates-key", + }), + /single active Gate/, + ); + }); + + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const engine = setup.engine(); + const started = await engine.execute(startCommand()); + const run = await setup.store.loadRun(started.runId); + const discover = run.stages.find( + (candidate) => candidate.kind === "discover", + ); + assert.ok(discover); + discover.status = "completed"; + run.revision++; + await setup.store.saveRun(run); + await assert.rejects( + engine.execute({ + action: "decide", + runId: run.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: run.revision, + decision: { + kind: "answer-questions", + answers: { "data-source": "Repository API" }, + }, + idempotencyKey: "wrong-stage-key", + }), + /waiting Discover stage/, + ); + }); +}); + +test("required profile eligibility fails before workspace mutation", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const profiles = structuredClone(setup.profiles); + for (const profile of profiles) { + if (profile.roles.includes("implement")) { + profile.selection.allTags = ["not-present"]; + } + } + await assert.rejects( + setup.engine("session-1", { profiles }).execute(startCommand()), + /No eligible implement Agent Profile/, + ); + assert.equal(setup.workspace.prepareCalls, 0); + assert.deepEqual(await setup.store.listRuns(), []); + }); +}); + +test("stale and illegal decisions fail without changing the run", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const engine = setup.engine(); + const started = await engine.execute(startCommand()); + await assert.rejects( + engine.execute({ + action: "decide", + runId: started.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: started.revision - 1, + decision: { kind: "answer-questions", answers: {} }, + idempotencyKey: "stale-key", + }), + /Stale workflow revision/, + ); + await assert.rejects( + engine.execute({ + action: "decide", + runId: started.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: started.revision, + decision: { kind: "answer-questions", answers: {} }, + idempotencyKey: "missing-answer-key", + }), + /requires an answer/, + ); + await assert.rejects( + engine.execute({ + action: "decide", + runId: started.runId, + gateId: started.openGate?.id ?? "missing", + expectedRevision: started.revision, + decision: { + kind: "approve-plan", + approved: true, + acceptedUiImpact: "none", + }, + idempotencyKey: "illegal-key", + }), + /not legal for questions/, + ); + assert.equal( + (await setup.store.loadRun(started.runId)).revision, + started.revision, + ); + }); +}); + +test("the closed Gate Decision matrix is exhaustive", () => { + const gateKinds: GateKind[] = [ + "questions", + "plan-approval", + "prototype-acceptance", + "implementation-context", + "recovery", + ]; + const decisionKinds: GateDecision["kind"][] = [ + "answer-questions", + "approve-plan", + "revise-plan", + "accept-prototype", + "refine-prototypes", + "select-implementation-context", + "report-context-preparation", + "recover", + ]; + const expected: Record = { + questions: ["answer-questions"], + "plan-approval": ["approve-plan", "revise-plan"], + "prototype-acceptance": ["accept-prototype", "refine-prototypes"], + "implementation-context": [ + "select-implementation-context", + "report-context-preparation", + ], + recovery: ["recover"], + }; + for (const gateKind of gateKinds) { + for (const decisionKind of decisionKinds) { + assert.equal( + decisionIsLegal(gateKind, decisionKind), + expected[gateKind].includes(decisionKind), + `${decisionKind} at ${gateKind}`, + ); + } + } +}); + +test("every illegal Gate Decision pair is rejected through execute", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const engine = setup.engine(); + const started = await engine.execute(startCommand()); + const run = await setup.store.loadRun(started.runId); + const gate = run.gates.find( + (candidate) => candidate.id === started.openGate?.id, + ); + assert.ok(gate); + const decisions: GateDecision[] = [ + { + kind: "answer-questions", + answers: { "data-source": "Repository API" }, + }, + { kind: "approve-plan", approved: true, acceptedUiImpact: "none" }, + { kind: "revise-plan", feedback: "Revise it" }, + { + kind: "accept-prototype", + candidateId: "candidate-1", + candidateRevision: 1, + browserReviewed: true, + }, + { kind: "refine-prototypes", feedback: "Refine it" }, + { kind: "select-implementation-context", strategy: "current-session" }, + { + kind: "report-context-preparation", + preparationId: "preparation-1", + planRevision: 1, + method: "smart_compact", + outcome: "unavailable", + }, + { kind: "recover", resolution: "retry" }, + ]; + const gateKinds: GateKind[] = [ + "questions", + "plan-approval", + "prototype-acceptance", + "implementation-context", + "recovery", + ]; + for (const gateKind of gateKinds) { + gate.kind = gateKind; + gate.payload = + gateKind === "questions" + ? { questions: [{ id: "data-source", prompt: "Which data source?" }] } + : undefined; + const discover = run.stages.find( + (candidate) => candidate.kind === "discover", + ); + const designStage = run.stages.find( + (candidate) => candidate.kind === "design", + ); + assert.ok(discover && designStage); + discover.status = gateKind === "questions" ? "waiting" : "completed"; + designStage.status = gateKind === "plan-approval" ? "waiting" : "pending"; + run.revision++; + gate.revision = run.revision; + await setup.store.saveRun(run); + for (const decision of decisions) { + if (decisionIsLegal(gateKind, decision.kind)) continue; + await assert.rejects( + engine.execute({ + action: "decide", + runId: run.runId, + gateId: gate.id, + expectedRevision: run.revision, + decision, + idempotencyKey: `matrix-${gateKind}-${decision.kind}`, + }), + new RegExp(`not legal for ${gateKind}`), + ); + } + } + }); +}); + +test("abort cancels an active reserved Assignment before persisting terminal state", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + let resolveStarted = () => {}; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let cancelled = false; + const executor: AssignmentExecutor = { + reservesCapacity: true, + async run(request) { + await request.onReserved?.(); + resolveStarted(); + return new Promise((resolve, reject) => { + request.signal.addEventListener( + "abort", + () => { + cancelled = true; + const error = Object.assign(new Error("cancelled"), { + assignmentMetadata: { + childSessionId: "child-aborted", + provider: "provider-a", + model: "model-a", + effort: "medium", + contextCapacity: 64_000, + transcript: [{ role: "assistant", text: "cancelled child" }], + }, + }); + reject(error); + }, + { once: true }, + ); + }); + }, + }; + const startPromise = setup + .engine("session-1", { executor }) + .execute(startCommand()); + await started; + const [active] = await setup.store.listRuns(); + assert.ok(active); + const aborted = await setup.engine("session-1", { executor }).execute({ + action: "abort", + runId: active.runId, + expectedRevision: active.revision, + idempotencyKey: "abort-active", + }); + const startResult = await startPromise; + assert.equal(cancelled, true); + assert.equal(startResult.status, "aborted"); + assert.equal(aborted.status, "aborted"); + const persisted = await setup.store.loadRun(active.runId); + const assignment = persisted.assignments.find( + (candidate) => candidate.role === "discover", + ); + assert.equal(persisted.status, "aborted"); + assert.equal(assignment?.status, "interrupted"); + assert.equal(assignment?.childSessionId, "child-aborted"); + assert.match( + await readFile(assignment?.transcriptArtifact?.path ?? "missing", "utf8"), + /cancelled child/, + ); + }); +}); + +test("abort times out without persisting terminal state when an executor will not stop", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + let resolveStarted = () => {}; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let rejectRun = (_error: Error) => {}; + const executor: AssignmentExecutor = { + reservesCapacity: true, + async run(request) { + await request.onReserved?.(); + resolveStarted(); + return new Promise((_resolve, reject) => { + rejectRun = reject; + }); + }, + }; + const startPromise = setup.engine("session-1", { executor }).execute(startCommand()); + await started; + const [active] = await setup.store.listRuns(); + assert.ok(active); + await assert.rejects( + setup.engine("session-1", { + executor, + assignmentAbortTimeoutMs: 5, + }).execute({ + action: "abort", + runId: active.runId, + expectedRevision: active.revision, + idempotencyKey: "abort-timeout", + }), + /Timed out stopping active Assignments/, + ); + assert.notEqual((await setup.store.loadRun(active.runId)).status, "aborted"); + rejectRun(new Error("executor finally stopped")); + await startPromise; + const stopped = await setup.store.loadRun(active.runId); + const retried = await setup.engine("session-1", { executor }).execute({ + action: "abort", + runId: stopped.runId, + expectedRevision: stopped.revision, + idempotencyKey: "abort-after-timeout", + }); + assert.equal(retried.status, "aborted"); + assert.equal( + retried.assignments.find((assignment) => assignment.role === "discover") + ?.status, + "interrupted", + ); + }); +}); + +test("abort fails closed for an Assignment owned by another live process", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await setup.engine().execute(startCommand()); + const current = await setup.store.loadRun(started.runId); + const profile = setup.profiles.find((candidate) => + candidate.roles.includes("implement"), + ); + const evidence = current.preflightEligibility.find( + (candidate) => candidate.role === "implement", + ); + assert.ok(profile && evidence); + current.assignments.push({ + id: "external-assignment", + stageId: `${current.runId}:implement`, + role: "implement", + profileName: profile.name, + profileDigest: profile.digest, + selection: { + eligibleProfiles: structuredClone(evidence.candidates), + selectedProfileName: profile.name, + rationale: "External owner fixture", + }, + status: "running", + mutates: true, + ownedPaths: ["src"], + executionToken: "external-token", + }); + current.status = "running"; + current.revision++; + await setup.store.saveRun(current, current.revision - 1); + await setup.store.createAssignmentLease( + current.runId, + "external-assignment", + { + token: "external-token", + processId: 200, + heartbeatAt: 10_000, + }, + ); + await assert.rejects( + setup.engine("session-1", { + processId: 100, + isProcessAlive: (processId) => processId === 200, + }).execute({ + action: "abort", + runId: current.runId, + expectedRevision: current.revision, + idempotencyKey: "abort-external", + }), + /Cannot safely abort Assignment external-assignment owned by live process 200/, + ); + assert.equal((await setup.store.loadRun(current.runId)).status, "running"); + }); +}); + +test("abort is revision checked and idempotent", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const engine = setup.engine(); + const started = await engine.execute(startCommand()); + const abort = { + action: "abort" as const, + runId: started.runId, + expectedRevision: started.revision, + idempotencyKey: "abort-key", + }; + const aborted = await engine.execute(abort); + assert.equal(aborted.status, "aborted"); + assert.deepEqual(await engine.execute(abort), aborted); + await assert.rejects( + engine.execute({ ...abort, idempotencyKey: "other-key" }), + /Stale workflow revision/, + ); + }); +}); + +test("stale running assignments become interrupted once and require Recovery", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await setup.engine().execute(startCommand()); + const run = await setup.store.loadRun(started.runId); + for (const gate of run.gates) gate.status = "satisfied"; + const completed = structuredClone(run.assignments[0]); + assert.ok(completed); + completed.id = "completed-assignment"; + completed.status = "succeeded"; + const running: WorkflowAssignment = { + ...structuredClone(completed), + id: "mutating-assignment", + role: "implement", + status: "running", + mutates: true, + }; + run.assignments = [completed, running]; + run.status = "running"; + run.revision++; + if (run.lease) { + run.lease.processId = 999; + run.lease.heartbeatAt = 0; + } + await setup.store.saveRun(run); + + const recoveryEngine = setup.engine("session-2", { + now: () => 100_000, + processId: 200, + isProcessAlive: () => false, + }); + const [recovered] = await recoveryEngine.recover(); + assert.equal(recovered?.openGate?.kind, "recovery"); + const recoveredRun = await setup.store.loadRun(run.runId); + assert.equal( + recoveredRun.assignments.find((item) => item.id === "mutating-assignment") + ?.status, + "interrupted", + ); + assert.equal( + recoveredRun.assignments.find( + (item) => item.id === "completed-assignment", + )?.status, + "succeeded", + ); + assert.deepEqual(await recoveryEngine.recover(), []); + + await assert.rejects( + setup.engine("session-1").execute({ + action: "abort", + runId: run.runId, + expectedRevision: recoveredRun.revision, + idempotencyKey: "old-session-abort", + }), + /Historical parent sessions/, + ); + const inspected = await recoveryEngine.execute({ + action: "decide", + runId: run.runId, + gateId: recovered?.openGate?.id ?? "missing", + expectedRevision: recovered?.revision ?? -1, + decision: { kind: "recover", resolution: "inspect" }, + idempotencyKey: "inspect-recovery-key", + }); + assert.equal(inspected.openGate?.kind, "recovery"); + const retry = await recoveryEngine.execute({ + action: "decide", + runId: run.runId, + gateId: inspected.openGate?.id ?? "missing", + expectedRevision: inspected.revision, + decision: { kind: "recover", resolution: "retry" }, + idempotencyKey: "recovery-key", + }); + assert.equal( + retry.assignments.find((item) => item.id === "mutating-assignment") + ?.status, + "interrupted", + ); + assert.equal(retry.openGate?.kind, "recovery"); + }); +}); + +test("workspace creation failures persist recoverable intent", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + setup.workspace.createError = new Error("git worktree failed"); + const engine = setup.engine(); + const failed = await engine.execute(startCommand()); + assert.equal(failed.status, "waiting"); + assert.equal(failed.workspace.state, "creating"); + assert.equal(failed.openGate?.kind, "recovery"); + assert.match(failed.error?.message ?? "", /git worktree failed/); + assert.deepEqual(await engine.execute(startCommand()), failed); + assert.equal(setup.workspace.createCalls, 1); + await assert.rejects( + setup + .engine("session-1", { + processId: 200, + isProcessAlive: () => true, + }) + .execute({ + action: "decide", + runId: failed.runId, + gateId: failed.openGate?.id ?? "missing", + expectedRevision: failed.revision, + decision: { kind: "recover", resolution: "retry" }, + idempotencyKey: "live-owner-retry-key", + }), + /still owned by a live process/, + ); + assert.equal( + (await setup.store.loadRun(failed.runId)).gates.find( + (gate) => gate.id === failed.openGate?.id, + )?.status, + "open", + ); + + setup.workspace.createError = undefined; + const recovered = await engine.execute({ + action: "decide", + runId: failed.runId, + gateId: failed.openGate?.id ?? "missing", + expectedRevision: failed.revision, + decision: { kind: "recover", resolution: "retry" }, + idempotencyKey: "workspace-retry-key", + }); + assert.equal(recovered.workspace.state, "ready"); + assert.equal(recovered.openGate?.kind, "questions"); + assert.equal(setup.workspace.reconcileCalls, 1); + }); +}); + +test("post-creation discovery failures retain the ready worktree", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { invalid: true }); + const failed = await setup.engine().execute(startCommand()); + assert.equal(failed.status, "failed"); + assert.equal(failed.workspace.state, "ready"); + assert.equal(failed.error?.code, "invalid-discovery-output"); + }); +}); + +test("discovery question bounds preserve complete model-facing Gate payloads", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", { + ...discoveryWithQuestions(), + questions: Array.from({ length: 21 }, (_, index) => ({ + id: `question-${index}`, + prompt: `Question ${index}?`, + })), + }); + const failed = await setup.engine().execute( + startCommand({ idempotencyKey: "too-many-questions" }), + ); + assert.equal(failed.status, "failed"); + assert.match(failed.error?.message ?? "", /at most 20 questions/); + }); +}); + +test("startup reconciliation resolves persisted workspace creation intent", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await setup.engine().execute(startCommand()); + const run = await setup.store.loadRun(started.runId); + for (const gate of run.gates) gate.status = "satisfied"; + run.workspace.state = "creating"; + run.status = "running"; + run.revision++; + if (run.lease) run.lease.heartbeatAt = 0; + await setup.store.saveRun(run); + + const recovery = setup.engine("session-2", { + now: () => 100_000, + processId: 200, + isProcessAlive: () => false, + }); + const [recovered] = await recovery.recover(); + assert.equal(setup.workspace.reconcileCalls, 1); + assert.equal(recovered?.workspace.state, "ready"); + assert.equal(recovered?.openGate?.kind, "recovery"); + }); +}); + +test("feature names and branch collisions fail with actionable guidance", async () => { + await withTempDir(async (directory) => { + const setup = await fixture(directory); + const engine = setup.engine(); + await assert.rejects( + engine.execute(startCommand({ featureName: "Bad Feature" })), + /lowercase kebab case/, + ); + assert.equal(setup.workspace.prepareCalls, 0); + + setup.workspace.branchExists = true; + await assert.rejects( + engine.execute(startCommand({ idempotencyKey: "collision-key" })), + /choose another descriptive feature name/, + ); + + setup.workspace.branchExists = false; + setup.executor.enqueue("discover", discoveryWithQuestions()); + const started = await engine.execute( + startCommand({ idempotencyKey: "owner-key" }), + ); + setup.workspace.branchExists = true; + await assert.rejects( + engine.execute(startCommand({ idempotencyKey: "second-owner-key" })), + new RegExp(`${started.runId}.*inspect or decide`), + ); + }); +}); diff --git a/extensions/pi-workflow/engine.ts b/extensions/pi-workflow/engine.ts new file mode 100644 index 0000000..6de1ffb --- /dev/null +++ b/extensions/pi-workflow/engine.ts @@ -0,0 +1,4684 @@ +import { createHash, randomUUID } from "node:crypto"; +import * as path from "node:path"; +import type { + AgentProfile, + AssignmentGitState, + AssignmentSelection, + CommandContinuation, + CommandReceipt, + FeatureScriptInsertionPoint, + GateDecision, + ProfileRole, + StageEligibilityEvidence, + UiImpact, + VerificationCommand, + VerificationEvidence, + WorkflowAssignment, + WorkflowConfig, + WorkflowFacts, + WorkflowGate, + WorkflowRun, + WorkflowStage, +} from "./domain.ts"; +import { eligibleProfiles, selectInitialDiscoveryProfile } from "./profiles.ts"; +import { enforceUiImpact } from "./ui-policy.ts"; +import { + readVerifiedPlanArtifact, + type StartRecord, + type WorkflowStore, +} from "./store.ts"; +import { + WorkspaceSafetyError, + type PrepareWorkspaceOptions, + type WorkspacePlan, +} from "./workspace.ts"; + +const FEATURE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_FEATURE_NAME_LENGTH = 80; +const DEFAULT_LEASE_STALE_MS = 30_000; +const DEFAULT_ASSIGNMENT_HEARTBEAT_MS = 10_000; +const DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS = 15_000; +const ACTIVE_ASSIGNMENT_TOKENS = new Set(); +interface ActiveAssignmentExecution { + runId: string; + assignmentId: string; + controller: AbortController; + settled: Promise; + resolveSettled: () => void; + resumeAfterAbort: Promise; + resolveResumeAfterAbort: () => void; + metadata?: AssignmentExecutionMetadata; + transcriptArtifact?: WorkflowAssignment["transcriptArtifact"]; +} +const ACTIVE_ASSIGNMENT_EXECUTIONS = new Map(); +const ACTIVE_SCRIPT_EXECUTIONS = new Map< + string, + { controller: AbortController; settled: Promise; resolve: () => void } +>(); +const ACTIVE_STARTS = new Map< + string, + { + fingerprint: string; + operation: Promise; + published: Promise; + } +>(); + +export interface FeatureScriptInput { + source: string; + args?: unknown; + insertionPoint: FeatureScriptInsertionPoint; + activeToolNames: string[]; +} + +export interface StartWorkflowCommand { + action: "start"; + request: string; + featureName: string; + idempotencyKey: string; + concurrency?: number; + background?: boolean; + script?: FeatureScriptInput; +} + +export interface StartAnalysisWorkflowCommand { + action: "start-analysis"; + request: string; + source: string; + args?: unknown; + activeToolNames: string[]; + idempotencyKey: string; +} + +export interface DecideWorkflowCommand { + action: "decide"; + runId: string; + gateId: string; + expectedRevision: number; + decision: GateDecision; + idempotencyKey: string; + activeToolNames?: string[]; +} + +export interface InspectWorkflowCommand { + action: "inspect"; + runId: string; +} + +export interface AbortWorkflowCommand { + action: "abort"; + runId: string; + expectedRevision: number; + idempotencyKey: string; +} + +interface ContextCommandBase { + runId: string; + gateId: string; + preparationId: string; + planRevision: number; + contextRevision: number; + expectedRevision: number; +} + +export interface ContinueContextCommand extends ContextCommandBase { + action: "continue-context"; + sourceSessionFile?: string; +} + +export interface ClaimContextHandoffCommand extends ContextCommandBase { + action: "claim-context-handoff"; + sourceSessionFile: string; + targetSessionFile: string; + reason: "new"; +} + +export interface ConsumeContextHandoffCommand extends ContextCommandBase { + action: "consume-context-handoff"; + targetSessionFile: string; + previousSessionFile: string; +} + +export interface CompleteNormalCompactionCommand extends ContextCommandBase { + action: "complete-normal-compaction"; + outcome: "succeeded" | "no-op" | "failed"; + error?: string; +} + +export interface RecoverContextIntentCommand extends ContextCommandBase { + action: "recover-context-intent"; + reason: + | "startup" + | "reload" + | "resume" + | "fork" + | "expired" + | "cancelled"; +} + +export type WorkflowCommand = + | StartWorkflowCommand + | StartAnalysisWorkflowCommand + | DecideWorkflowCommand + | InspectWorkflowCommand + | AbortWorkflowCommand + | ContinueContextCommand + | ClaimContextHandoffCommand + | ConsumeContextHandoffCommand + | CompleteNormalCompactionCommand + | RecoverContextIntentCommand; + +export interface WorkflowView { + runId: string; + revision: number; + status: WorkflowRun["status"]; + background: boolean; + workspace: WorkflowRun["workspace"]; + openGate?: WorkflowGate; + uiImpact?: UiImpact; + uiImpactEvidence: string[]; + affectedUiSurfaces: string[]; + planArtifact?: WorkflowRun["planArtifact"]; + reportArtifact?: WorkflowRun["reportArtifact"]; + implementationContext?: WorkflowRun["implementationContext"]; + stages: WorkflowStage[]; + assignments: WorkflowAssignment[]; + mutatorProfileNames: string[]; + reviewCycles: number; + verificationCycles: number; + verificationEvidence?: WorkflowRun["verificationEvidence"]; + reviewVerdict?: WorkflowRun["reviewVerdict"]; + error?: WorkflowRun["error"]; +} + +export interface AssignmentExecutionMetadata { + childSessionId?: string; + provider?: string; + model?: string; + effort?: string; + contextCapacity?: number; + transcript: unknown[]; + gitBefore?: AssignmentGitState; + gitAfter?: AssignmentGitState; +} + +export interface AssignmentExecutionResult { + kind: "pi-workflow-assignment-result"; + output: unknown; + metadata: AssignmentExecutionMetadata; +} + +export interface AssignmentExecutionError extends Error { + assignmentMetadata: AssignmentExecutionMetadata; +} + +export interface AssignmentRequest { + runId: string; + role: ProfileRole | "remediate"; + request: string; + profile: AgentProfile; + facts: WorkflowFacts; + decisions: WorkflowRun["decisions"]; + plannerCandidates?: StageEligibilityEvidence[]; + cwd: string; + signal: AbortSignal; + mutates?: boolean; + ownedPaths?: string[]; + captureGitState?: boolean; + allowedReadTools?: string[]; + runConcurrency?: number; + approvedPlan?: string; + verificationEvidence?: VerificationEvidence; + reviewDiff?: string; + onReserved?: () => Promise; +} + +export interface AssignmentExecutor { + readonly reservesCapacity?: boolean; + run(request: AssignmentRequest): Promise; +} + +export interface DiscoveryOutput { + questions: Array<{ id: string; prompt: string }>; + candidatePaths: string[]; + languages: string[]; + tags: string[]; + riskTags: string[]; +} + +export interface PlannedAssignmentOutput { + role: "prototype" | "implement" | "verify" | "review"; + profileName: string; + rationale: string; + mutates: boolean; + ownedPaths: string[]; +} + +export interface PlannedVerificationCommand { + executable: string; + args: string[]; + ownedPaths: string[]; + kind: "test" | "typecheck" | "lint" | "build" | "browser" | "generation"; +} + +export interface DesignOutput { + plan: string; + uiImpact: UiImpact; + uiImpactEvidence: string[]; + affectedUiSurfaces: string[]; + assignments: PlannedAssignmentOutput[]; + acceptanceCriteria: string[]; + verificationCommands: PlannedVerificationCommand[]; +} + +export interface PrototypeOutput { + path: string; + route: string; + structuralRationale: string; + responsiveChecks: string[]; + browserEvidence: string[]; + launchInstructions: string; +} + +export interface ImplementationOutput { + summary: string; + changedPaths: string[]; + focusedTests: string[]; +} + +export interface VerificationOutput { + succeeded: boolean; + focusedBehavioralTests: boolean; + summary: string; +} + +export interface ReviewOutput { + specification: { + verdict: "approved" | "changes_requested"; + findings: Array<{ severity: string; message: string; path?: string }>; + }; + standards: { + verdict: "approved" | "changes_requested"; + findings: Array<{ severity: string; message: string; path?: string }>; + }; +} + +export interface WorkspaceManager { + prepare(options: PrepareWorkspaceOptions): Promise; + create(plan: WorkspacePlan): Promise; + reconcile( + workspace: WorkflowRun["workspace"], + expectedWorktreeIdentity?: string, + ): Promise; +} + +export interface ReviewDiffProvider { + read(worktreeRoot: string, baseHead?: string): Promise; +} + +export interface HostCommandExecutor { + run(options: { + worktreeRoot: string; + ownedPaths: string[]; + executable: string; + args: string[]; + }): Promise<{ + result: { code: number; stdout: string; stderr: string }; + }>; +} + +export interface PrototypePolicyValidator { + validateCandidate(options: { + worktreeRoot: string; + assignment: WorkflowAssignment; + output: PrototypeOutput; + }): Promise<{ artifactFiles: string[]; visualDigest: string }>; + validateCleanup(options: { + worktreeRoot: string; + candidates: WorkflowRun["prototypeCandidates"]; + }): Promise; +} + +export interface ScriptRuntime { + run(options: { + source: string; + args: unknown; + cwd: string; + signal: AbortSignal; + onAssign: ( + role: string, + prompt: string, + signal: AbortSignal, + requestId: number, + ) => Promise<{ ok: boolean; output: string; structured?: unknown; error?: string }>; + onStage: (title: string) => void; + }): Promise; +} + +export interface WorkflowEngineOptions { + store: WorkflowStore; + workspace: WorkspaceManager; + executor: AssignmentExecutor; + hostCommands?: HostCommandExecutor; + reviewDiff?: ReviewDiffProvider; + scriptRuntime?: ScriptRuntime; + prototypeValidator?: PrototypePolicyValidator; + sourceRoot: string; + parentSessionId: string; + effectiveConfig: WorkflowConfig; + profiles: AgentProfile[]; + now?: () => number; + createId?: () => string; + processId?: number; + leaseStaleMs?: number; + isProcessAlive?: (processId: number) => boolean; + assignmentHeartbeatMs?: number; + assignmentAbortTimeoutMs?: number; + afterAssignmentPersist?: ( + assignment: WorkflowAssignment, + run: WorkflowRun, + ) => void | Promise; + afterRunPersist?: (run: WorkflowRun) => void | Promise; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isAssignmentExecutionResult( + value: unknown, +): value is AssignmentExecutionResult { + return ( + isRecord(value) && + value.kind === "pi-workflow-assignment-result" && + isRecord(value.metadata) && + Array.isArray(value.metadata.transcript) + ); +} + +function isAssignmentExecutionError( + value: unknown, +): value is AssignmentExecutionError { + return ( + value instanceof Error && + "assignmentMetadata" in value && + isRecord(value.assignmentMetadata) && + Array.isArray(value.assignmentMetadata.transcript) + ); +} + +async function waitForAssignmentStops( + executions: ActiveAssignmentExecution[], + timeoutMs = DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS, +) { + if (executions.length === 0) return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled(executions.map((execution) => execution.settled)), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error( + "Timed out stopping active Assignments; Workflow Run was not aborted", + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function applyExecutionMetadata( + assignment: WorkflowAssignment, + metadata: AssignmentExecutionMetadata | undefined, + transcriptArtifact: WorkflowAssignment["transcriptArtifact"], +) { + if (!metadata) return; + assignment.childSessionId = metadata.childSessionId; + assignment.effectiveProvider = metadata.provider; + assignment.effectiveModel = metadata.model; + assignment.effectiveEffort = metadata.effort; + assignment.contextCapacity = metadata.contextCapacity; + assignment.transcriptArtifact = transcriptArtifact; + assignment.gitBefore = metadata.gitBefore; + assignment.gitAfter = metadata.gitAfter; +} + +function assertKeys( + value: Record, + required: string[], + optional: string[] = [], +) { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`Unexpected field ${key}`); + } + for (const key of required) { + if (!(key in value)) throw new Error(`Missing required field ${key}`); + } +} + +function nonEmptyString(value: unknown, label: string) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${label} must be a non-empty string`); + } +} + +function nonNegativeInteger(value: unknown, label: string) { + if (!Number.isInteger(value) || (value as number) < 0) { + throw new Error(`${label} must be a non-negative integer`); + } +} + +function validateDecisionShape(value: unknown): asserts value is GateDecision { + if (!isRecord(value) || typeof value.kind !== "string") { + throw new Error("decision must be an object with a kind"); + } + if (value.kind === "answer-questions") { + assertKeys(value, ["kind", "answers"]); + if (!isRecord(value.answers)) throw new Error("answers must be an object"); + for (const [id, answer] of Object.entries(value.answers)) { + nonEmptyString(id, "question ID"); + nonEmptyString(answer, `answer for ${id}`); + } + return; + } + if (value.kind === "approve-plan") { + assertKeys(value, ["kind", "approved", "acceptedUiImpact"], ["comment"]); + if (value.approved !== true) + throw new Error("approve-plan requires approved=true"); + if ( + value.acceptedUiImpact !== "none" && + value.acceptedUiImpact !== "minor" && + value.acceptedUiImpact !== "non-trivial" + ) { + throw new Error("acceptedUiImpact is invalid"); + } + if (value.comment !== undefined && typeof value.comment !== "string") { + throw new Error("comment must be a string"); + } + return; + } + if (value.kind === "revise-plan" || value.kind === "refine-prototypes") { + assertKeys(value, ["kind", "feedback"]); + nonEmptyString(value.feedback, "feedback"); + return; + } + if (value.kind === "accept-prototype") { + assertKeys( + value, + ["kind", "candidateId", "candidateRevision", "browserReviewed"], + ["feedback"], + ); + nonEmptyString(value.candidateId, "candidateId"); + nonNegativeInteger(value.candidateRevision, "candidateRevision"); + if (value.browserReviewed !== true) { + throw new Error("accept-prototype requires browserReviewed=true"); + } + if (value.feedback !== undefined && typeof value.feedback !== "string") { + throw new Error("feedback must be a string"); + } + return; + } + if (value.kind === "select-implementation-context") { + assertKeys(value, ["kind", "strategy"]); + if ( + value.strategy !== "fresh-session" && + value.strategy !== "smart-compact" && + value.strategy !== "current-session" + ) { + throw new Error("implementation context strategy is invalid"); + } + return; + } + if (value.kind === "report-context-preparation") { + assertKeys( + value, + ["kind", "preparationId", "planRevision", "method", "outcome"], + ["error"], + ); + nonEmptyString(value.preparationId, "preparationId"); + nonNegativeInteger(value.planRevision, "planRevision"); + if (value.method !== "smart_compact") throw new Error("method is invalid"); + if ( + value.outcome !== "unavailable" && + value.outcome !== "succeeded" && + value.outcome !== "no-op" && + value.outcome !== "failed" + ) { + throw new Error("context preparation outcome is invalid"); + } + if (value.outcome === "failed") { + nonEmptyString(value.error, "context preparation error"); + } else if (value.error !== undefined) { + throw new Error("context preparation error is only valid for failure"); + } + return; + } + if (value.kind === "recover") { + assertKeys(value, ["kind", "resolution"]); + if ( + value.resolution !== "retry" && + value.resolution !== "inspect" && + value.resolution !== "abort" + ) { + throw new Error("recovery resolution is invalid"); + } + return; + } + throw new Error(`Unknown decision kind ${value.kind}`); +} + +function validateActiveToolNames(value: unknown) { + if ( + !Array.isArray(value) || + value.some((name) => typeof name !== "string" || name.trim().length === 0) + ) { + throw new Error("activeToolNames must be an array of non-empty strings"); + } +} + +function validateFeatureScript(value: unknown) { + if (!isRecord(value)) throw new Error("script must be an object"); + assertKeys( + value, + ["source", "insertionPoint", "activeToolNames"], + ["args"], + ); + nonEmptyString(value.source, "script source"); + if ( + value.insertionPoint !== "before-design" && + value.insertionPoint !== "after-design" && + value.insertionPoint !== "before-review" + ) { + throw new Error("script insertionPoint is invalid"); + } + validateActiveToolNames(value.activeToolNames); +} + +function validateWorkflowCommand( + value: unknown, +): asserts value is WorkflowCommand { + if (!isRecord(value) || typeof value.action !== "string") { + throw new Error("workflow command must be an object with an action"); + } + if (value.action === "start") { + assertKeys( + value, + ["action", "request", "featureName", "idempotencyKey"], + ["background", "concurrency", "script"], + ); + nonEmptyString(value.request, "request"); + nonEmptyString(value.featureName, "featureName"); + nonEmptyString(value.idempotencyKey, "idempotencyKey"); + if ( + value.concurrency !== undefined && + (typeof value.concurrency !== "number" || + !Number.isInteger(value.concurrency) || + value.concurrency < 1) + ) { + throw new Error("concurrency must be a positive integer"); + } + if ( + value.background !== undefined && + typeof value.background !== "boolean" + ) { + throw new Error("background must be a boolean"); + } + if (value.script !== undefined) validateFeatureScript(value.script); + return; + } + if (value.action === "start-analysis") { + assertKeys( + value, + ["action", "request", "source", "activeToolNames", "idempotencyKey"], + ["args"], + ); + nonEmptyString(value.request, "request"); + nonEmptyString(value.source, "source"); + nonEmptyString(value.idempotencyKey, "idempotencyKey"); + validateActiveToolNames(value.activeToolNames); + return; + } + if (value.action === "inspect") { + assertKeys(value, ["action", "runId"]); + nonEmptyString(value.runId, "runId"); + return; + } + if (value.action === "abort") { + assertKeys(value, [ + "action", + "runId", + "expectedRevision", + "idempotencyKey", + ]); + nonEmptyString(value.runId, "runId"); + nonNegativeInteger(value.expectedRevision, "expectedRevision"); + nonEmptyString(value.idempotencyKey, "idempotencyKey"); + return; + } + if ( + value.action === "continue-context" || + value.action === "claim-context-handoff" || + value.action === "consume-context-handoff" || + value.action === "complete-normal-compaction" || + value.action === "recover-context-intent" + ) { + const actionFields: Record = { + "continue-context": { required: [], optional: ["sourceSessionFile"] }, + "claim-context-handoff": { + required: ["sourceSessionFile", "targetSessionFile", "reason"], + }, + "consume-context-handoff": { + required: ["targetSessionFile", "previousSessionFile"], + }, + "complete-normal-compaction": { + required: ["outcome"], + optional: ["error"], + }, + "recover-context-intent": { required: ["reason"] }, + }; + const fields = actionFields[value.action]; + if (!fields) throw new Error("Unknown context command"); + assertKeys( + value, + [ + "action", + "runId", + "gateId", + "preparationId", + "planRevision", + "contextRevision", + "expectedRevision", + ...fields.required, + ], + fields.optional, + ); + nonEmptyString(value.runId, "runId"); + nonEmptyString(value.gateId, "gateId"); + nonEmptyString(value.preparationId, "preparationId"); + nonNegativeInteger(value.planRevision, "planRevision"); + nonNegativeInteger(value.contextRevision, "contextRevision"); + nonNegativeInteger(value.expectedRevision, "expectedRevision"); + if ("sourceSessionFile" in value && value.sourceSessionFile !== undefined) { + nonEmptyString(value.sourceSessionFile, "sourceSessionFile"); + } + if ("targetSessionFile" in value) { + nonEmptyString(value.targetSessionFile, "targetSessionFile"); + } + if ("previousSessionFile" in value) { + nonEmptyString(value.previousSessionFile, "previousSessionFile"); + } + if (value.action === "claim-context-handoff" && value.reason !== "new") { + throw new Error("handoff claim reason must be new"); + } + if (value.action === "recover-context-intent") { + if ( + value.reason !== "startup" && + value.reason !== "reload" && + value.reason !== "resume" && + value.reason !== "fork" && + value.reason !== "expired" && + value.reason !== "cancelled" + ) { + throw new Error("context recovery reason is invalid"); + } + } + if (value.action === "complete-normal-compaction") { + if ( + value.outcome !== "succeeded" && + value.outcome !== "no-op" && + value.outcome !== "failed" + ) { + throw new Error("normal compaction outcome is invalid"); + } + if (value.outcome === "failed") { + nonEmptyString(value.error, "normal compaction error"); + } else if (value.error !== undefined) { + throw new Error("normal compaction error is only valid for failure"); + } + } + return; + } + if (value.action === "decide") { + assertKeys(value, [ + "action", + "runId", + "gateId", + "expectedRevision", + "decision", + "idempotencyKey", + ], ["activeToolNames"]); + nonEmptyString(value.runId, "runId"); + nonEmptyString(value.gateId, "gateId"); + nonNegativeInteger(value.expectedRevision, "expectedRevision"); + nonEmptyString(value.idempotencyKey, "idempotencyKey"); + if ( + value.activeToolNames !== undefined && + (!Array.isArray(value.activeToolNames) || + value.activeToolNames.some((name) => typeof name !== "string")) + ) { + throw new Error("activeToolNames must be an array of strings"); + } + validateDecisionShape(value.decision); + return; + } + throw new Error(`Unknown workflow action ${value.action}`); +} + +function stringList(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string") + ) { + throw new Error(`${label} must be an array of strings`); + } + return value.map((entry) => entry.trim()).filter(Boolean); +} + +function discoveryOutput(value: unknown): DiscoveryOutput { + if (!isRecord(value) || !Array.isArray(value.questions)) { + throw new Error("Discovery Assignment returned an invalid result"); + } + if (value.questions.length > 20) { + throw new Error("Discovery may return at most 20 questions"); + } + const questions = value.questions.map((question, index) => { + if ( + !isRecord(question) || + typeof question.id !== "string" || + question.id.trim().length === 0 || + question.id.length > 160 || + typeof question.prompt !== "string" || + question.prompt.trim().length === 0 || + question.prompt.length > 2_000 + ) { + throw new Error(`Discovery question ${index} is invalid`); + } + return { id: question.id.trim(), prompt: question.prompt.trim() }; + }); + if ( + new Set(questions.map((question) => question.id)).size !== questions.length + ) { + throw new Error("Discovery question IDs must be unique"); + } + return { + questions, + candidatePaths: stringList(value.candidatePaths ?? [], "candidatePaths"), + languages: stringList(value.languages ?? [], "languages"), + tags: stringList(value.tags ?? [], "tags").map((entry) => + entry.toLowerCase(), + ), + riskTags: stringList(value.riskTags ?? [], "riskTags").map((entry) => + entry.toLowerCase(), + ), + }; +} + +function designOutput(value: unknown): DesignOutput { + if ( + !isRecord(value) || + typeof value.plan !== "string" || + value.plan.trim().length === 0 || + (value.uiImpact !== "none" && + value.uiImpact !== "minor" && + value.uiImpact !== "non-trivial") + ) { + throw new Error("Design Assignment returned an invalid result"); + } + const rawAssignments = value.assignments ?? []; + if (!Array.isArray(rawAssignments)) { + throw new Error("Design assignments must be an array"); + } + const assignments = rawAssignments.map((assignment, index) => { + if ( + !isRecord(assignment) || + (assignment.role !== "prototype" && + assignment.role !== "implement" && + assignment.role !== "verify" && + assignment.role !== "review") || + typeof assignment.profileName !== "string" || + assignment.profileName.trim().length === 0 || + typeof assignment.rationale !== "string" || + assignment.rationale.trim().length === 0 || + typeof assignment.mutates !== "boolean" + ) { + throw new Error(`Design assignment ${index} is invalid`); + } + const role = assignment.role as PlannedAssignmentOutput["role"]; + return { + role, + profileName: assignment.profileName.trim(), + rationale: assignment.rationale.trim(), + mutates: assignment.mutates, + ownedPaths: stringList( + assignment.ownedPaths, + `Design assignment ${index} ownedPaths`, + ), + }; + }); + const acceptanceCriteria = stringList( + value.acceptanceCriteria ?? [], + "acceptanceCriteria", + ); + const rawCommands = value.verificationCommands ?? []; + if (!Array.isArray(rawCommands)) { + throw new Error("verificationCommands must be an array"); + } + const verificationCommands = rawCommands.map((command, index) => { + if ( + !isRecord(command) || + typeof command.executable !== "string" || + command.executable.trim().length === 0 || + (command.kind !== "test" && + command.kind !== "typecheck" && + command.kind !== "lint" && + command.kind !== "build" && + command.kind !== "browser" && + command.kind !== "generation") + ) { + throw new Error(`Verification command ${index} is invalid`); + } + return { + executable: command.executable.trim(), + args: stringList(command.args, `Verification command ${index} args`), + ownedPaths: stringList( + command.ownedPaths, + `Verification command ${index} ownedPaths`, + ), + kind: command.kind as PlannedVerificationCommand["kind"], + }; + }); + if (acceptanceCriteria.length === 0) { + throw new Error("Design requires at least one acceptance criterion"); + } + if (!verificationCommands.some((command) => command.kind === "test")) { + throw new Error("Design requires at least one focused behavioral test command"); + } + return { + plan: value.plan.trim(), + uiImpact: value.uiImpact, + uiImpactEvidence: stringList( + value.uiImpactEvidence ?? [], + "uiImpactEvidence", + ), + affectedUiSurfaces: stringList( + value.affectedUiSurfaces ?? [], + "affectedUiSurfaces", + ), + assignments, + acceptanceCriteria, + verificationCommands, + }; +} + +function prototypeOutput(value: unknown): PrototypeOutput { + if ( + !isRecord(value) || + typeof value.path !== "string" || + value.path.trim().length === 0 || + typeof value.route !== "string" || + value.route.trim().length === 0 || + typeof value.structuralRationale !== "string" || + value.structuralRationale.trim().length === 0 || + typeof value.launchInstructions !== "string" || + value.launchInstructions.trim().length === 0 + ) { + throw new Error("Prototype Assignment returned an invalid result"); + } + const responsiveChecks = stringList( + value.responsiveChecks, + "prototype responsiveChecks", + ); + const browserEvidence = stringList( + value.browserEvidence, + "prototype browserEvidence", + ); + if (responsiveChecks.length === 0 || browserEvidence.length === 0) { + throw new Error("Prototype requires responsive checks and browser evidence"); + } + return { + path: value.path.trim(), + route: value.route.trim(), + structuralRationale: value.structuralRationale.trim(), + responsiveChecks, + browserEvidence, + launchInstructions: value.launchInstructions.trim(), + }; +} + +function implementationOutput(value: unknown): ImplementationOutput { + if ( + !isRecord(value) || + typeof value.summary !== "string" || + value.summary.trim().length === 0 + ) { + throw new Error("Implementation Assignment returned an invalid result"); + } + return { + summary: value.summary.trim(), + changedPaths: stringList(value.changedPaths ?? [], "changedPaths"), + focusedTests: stringList(value.focusedTests ?? [], "focusedTests"), + }; +} + +function verificationOutput(value: unknown): VerificationOutput { + if ( + !isRecord(value) || + typeof value.succeeded !== "boolean" || + typeof value.focusedBehavioralTests !== "boolean" || + typeof value.summary !== "string" || + value.summary.trim().length === 0 + ) { + throw new Error("Verification Assignment returned an invalid result"); + } + return { + succeeded: value.succeeded, + focusedBehavioralTests: value.focusedBehavioralTests, + summary: value.summary.trim(), + }; +} + +function reviewAxis(value: unknown, label: string): ReviewOutput["specification"] { + if ( + !isRecord(value) || + (value.verdict !== "approved" && value.verdict !== "changes_requested") || + !Array.isArray(value.findings) + ) { + throw new Error(`${label} review axis is invalid`); + } + return { + verdict: value.verdict, + findings: value.findings.map((finding, index) => { + if ( + !isRecord(finding) || + typeof finding.severity !== "string" || + typeof finding.message !== "string" || + finding.message.trim().length === 0 || + (finding.path !== undefined && typeof finding.path !== "string") + ) { + throw new Error(`${label} review finding ${index} is invalid`); + } + return { + severity: finding.severity, + message: finding.message.trim(), + ...(finding.path ? { path: finding.path } : {}), + }; + }), + }; +} + +function reviewOutput(value: unknown): ReviewOutput { + if (!isRecord(value)) throw new Error("Review Assignment returned an invalid result"); + return { + specification: reviewAxis(value.specification, "Specification"), + standards: reviewAxis(value.standards, "Standards"), + }; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function fingerprint(value: unknown) { + return createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function requestTerms(request: string) { + return [ + ...new Set( + request + .toLowerCase() + .split(/[^a-z0-9-]+/) + .map((term) => term.trim()) + .filter(Boolean), + ), + ]; +} + +function activeGate(run: WorkflowRun) { + for (let index = run.gates.length - 1; index >= 0; index--) { + const gate = run.gates[index]; + if (gate?.status === "open") return gate; + } + return undefined; +} + +function isTerminalStatus(status: WorkflowRun["status"]) { + return status === "completed" || status === "failed" || status === "aborted"; +} + +function openGate(run: WorkflowRun, gate: WorkflowGate) { + run.gates.push(gate); + run.deliveries.push({ + key: `gate:${run.runId}:${gate.id}:${gate.revision}`, + state: "pending", + createdAt: gate.openedAt, + }); +} + +function view(run: WorkflowRun): WorkflowView { + return { + runId: run.runId, + revision: run.revision, + status: run.status, + background: run.background, + workspace: structuredClone(run.workspace), + ...(activeGate(run) ? { openGate: structuredClone(activeGate(run)) } : {}), + ...(run.uiImpact ? { uiImpact: run.uiImpact } : {}), + uiImpactEvidence: [...run.uiImpactEvidence], + affectedUiSurfaces: [...run.affectedUiSurfaces], + ...(run.planArtifact ? { planArtifact: { ...run.planArtifact } } : {}), + ...(run.reportArtifact ? { reportArtifact: { ...run.reportArtifact } } : {}), + ...(run.implementationContext + ? { implementationContext: structuredClone(run.implementationContext) } + : {}), + stages: structuredClone(run.stages), + assignments: structuredClone(run.assignments), + mutatorProfileNames: [...run.mutatorProfileNames], + reviewCycles: run.reviewCycles, + verificationCycles: run.verificationCycles, + ...(run.verificationEvidence + ? { verificationEvidence: structuredClone(run.verificationEvidence) } + : {}), + ...(run.reviewVerdict + ? { reviewVerdict: structuredClone(run.reviewVerdict) } + : {}), + ...(run.error ? { error: { ...run.error } } : {}), + }; +} + +function selectionEvidence( + profiles: AgentProfile[], + facts: WorkflowFacts, + role: ProfileRole, +): { profile: AgentProfile; selection: AssignmentSelection } { + const evaluations = eligibleProfiles(profiles, facts, role); + const selected = evaluations.find((evaluation) => evaluation.eligible); + if (!selected) + throw new Error(`No eligible ${role} Agent Profile is available`); + return { + profile: selected.profile, + selection: { + eligibleProfiles: evaluations.map((evaluation) => ({ + profileName: evaluation.profile.name, + profileDigest: evaluation.profile.digest, + eligible: evaluation.eligible, + reasons: [...evaluation.reasons], + specificity: evaluation.specificity, + })), + selectedProfileName: selected.profile.name, + rationale: `Selected the highest-specificity eligible ${role} profile with a code-point name tie-breaker.`, + }, + }; +} + +function stage(run: WorkflowRun, kind: WorkflowStage["kind"]) { + const found = run.stages.find((candidate) => candidate.kind === kind); + if (!found) throw new Error(`Workflow stage ${kind} is missing`); + return found; +} + +export function decisionIsLegal( + gateKind: WorkflowGate["kind"], + decisionKind: GateDecision["kind"], +) { + return ( + (gateKind === "questions" && decisionKind === "answer-questions") || + (gateKind === "plan-approval" && + (decisionKind === "approve-plan" || decisionKind === "revise-plan")) || + (gateKind === "prototype-acceptance" && + (decisionKind === "accept-prototype" || + decisionKind === "refine-prototypes")) || + (gateKind === "implementation-context" && + (decisionKind === "select-implementation-context" || + decisionKind === "report-context-preparation")) || + (gateKind === "recovery" && decisionKind === "recover") + ); +} + +function decisionContinuation( + gateKind: WorkflowGate["kind"], + decision: GateDecision, +): CommandContinuation { + if (gateKind === "questions") return "design"; + if (gateKind === "plan-approval" && decision.kind === "revise-plan") { + return "design"; + } + if (gateKind === "plan-approval" && decision.kind === "approve-plan") { + return "prototype"; + } + if (gateKind === "prototype-acceptance") return "prototype"; + if ( + gateKind === "implementation-context" && + ((decision.kind === "select-implementation-context" && + decision.strategy === "current-session") || + (decision.kind === "report-context-preparation" && + (decision.outcome === "succeeded" || decision.outcome === "no-op"))) + ) { + return "implementation"; + } + if (gateKind === "recovery") return "recovery"; + return "none"; +} + +function legalDecision(gate: WorkflowGate, decision: GateDecision) { + if (!decisionIsLegal(gate.kind, decision.kind)) { + throw new Error(`Decision ${decision.kind} is not legal for ${gate.kind}`); + } +} + +function validateDecisionPayload(gate: WorkflowGate, decision: GateDecision) { + if (decision.kind === "answer-questions") { + if (!isRecord(gate.payload) || !Array.isArray(gate.payload.questions)) { + throw new Error("Questions Gate payload is invalid"); + } + for (const question of gate.payload.questions) { + if (!isRecord(question) || typeof question.id !== "string") { + throw new Error("Questions Gate payload is invalid"); + } + const answer = decision.answers[question.id]; + if (typeof answer !== "string" || answer.trim().length === 0) { + throw new Error(`Question ${question.id} requires an answer`); + } + } + } + if ( + (decision.kind === "revise-plan" || + decision.kind === "refine-prototypes") && + decision.feedback.trim().length === 0 + ) { + throw new Error(`${decision.kind} feedback is required`); + } + if (decision.kind === "accept-prototype") { + if (!isRecord(gate.payload) || !Array.isArray(gate.payload.candidates)) { + throw new Error("Prototype Acceptance Gate payload is invalid"); + } + const candidate = gate.payload.candidates.find( + (entry) => + isRecord(entry) && + entry.id === decision.candidateId && + entry.revision === decision.candidateRevision, + ); + if (!candidate) { + throw new Error("Accepted prototype candidate or revision is stale"); + } + } + if ( + decision.kind === "report-context-preparation" && + decision.outcome === "failed" && + decision.error.trim().length === 0 + ) { + throw new Error("Failed context preparation requires an actionable error"); + } +} + +function processAlive(processId: number) { + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +} + +export class WorkflowEngine { + private readonly options: WorkflowEngineOptions; + private readonly now: () => number; + private readonly createId: () => string; + private readonly processId: number; + private readonly leaseStaleMs: number; + private readonly isProcessAlive: (processId: number) => boolean; + private readonly assignmentHeartbeatMs: number; + private readonly assignmentAbortTimeoutMs: number; + + constructor(options: WorkflowEngineOptions) { + this.options = options; + this.now = options.now ?? Date.now; + this.createId = options.createId ?? randomUUID; + this.processId = options.processId ?? process.pid; + this.leaseStaleMs = options.leaseStaleMs ?? DEFAULT_LEASE_STALE_MS; + this.isProcessAlive = options.isProcessAlive ?? processAlive; + this.assignmentHeartbeatMs = + options.assignmentHeartbeatMs ?? DEFAULT_ASSIGNMENT_HEARTBEAT_MS; + this.assignmentAbortTimeoutMs = + options.assignmentAbortTimeoutMs ?? DEFAULT_ASSIGNMENT_ABORT_TIMEOUT_MS; + } + + async execute(command: WorkflowCommand): Promise { + validateWorkflowCommand(command); + if (command.action === "start") return this.start(command); + if (command.action === "start-analysis") return this.startAnalysis(command); + const run = await this.options.store.loadRun(command.runId); + if (command.action === "inspect") return view(run); + if (command.action === "consume-context-handoff") { + return this.consumeContextHandoff(run, command); + } + if (command.action === "recover-context-intent") { + return this.recoverContextIntent(run, command); + } + this.assertActiveParent(run); + if (command.action === "abort") return this.abort(run, command); + if (command.action === "decide") return this.decide(run, command); + if (command.action === "continue-context") { + return this.continueContext(run, command); + } + if (command.action === "claim-context-handoff") { + return this.claimContextHandoff(run, command); + } + return this.completeNormalCompaction(run, command); + } + + async recover() { + const recovered: WorkflowView[] = []; + for (const current of await this.options.store.listRuns()) { + if ( + current.status === "completed" || + current.status === "failed" || + current.status === "aborted" + ) { + continue; + } + let run = current; + if ( + run.workspace.state === "creating" && + run.lease && + this.isProcessAlive(run.lease.processId) + ) { + continue; + } + if (run.workspace.state === "creating") { + const workspace = await this.options.workspace.reconcile( + run.workspace, + run.lease?.worktreeIdentity, + ); + run = await this.persistWorkspaceOutcome(run, workspace); + if (run.workspace.state !== "ready") { + recovered.push(view(run)); + continue; + } + } + if ( + run.implementationContext?.status === "normal-compact-running" && + this.leaseIsStale(run) + ) { + run = await this.transition(run, (next) => { + const context = next.implementationContext; + if (!context) throw new Error("Implementation Context state is missing"); + context.status = "failed"; + context.outcome = "failed"; + context.error = + "Normal compaction was interrupted before its callback completed"; + context.runRevision = next.revision + 1; + next.status = "waiting"; + }); + recovered.push(view(run)); + continue; + } + const running = run.assignments.filter( + (assignment) => + assignment.status === "running" || + (assignment.status === "queued" && Boolean(assignment.executionToken)), + ); + if (await this.hasActiveAssignmentLease(run.runId, running)) continue; + if (!this.leaseIsStale(run)) continue; + run = await this.transition(run, (next) => { + next.lease = { + processId: this.processId, + activeParentSessionId: this.options.parentSessionId, + heartbeatAt: this.now(), + worktreeIdentity: next.workspace.root, + }; + next.parentSessionIds = [ + ...new Set([...next.parentSessionIds, this.options.parentSessionId]), + ]; + }); + if (running.length === 0) { + const claimedRevision = run.revision; + const resumed = await this.resumePersistedContinuation(run); + if (resumed.revision !== claimedRevision) { + recovered.push(view(resumed)); + continue; + } + } + for (const assignment of running) { + if (assignment.executionToken) { + await this.options.store.releaseAssignmentLease( + run.runId, + assignment.id, + assignment.executionToken, + ); + } + } + const alreadyWaiting = activeGate(run)?.kind === "recovery"; + if (running.length === 0 && alreadyWaiting) continue; + run = await this.transition(run, (next) => { + const interruptedIds = new Set(running.map((assignment) => assignment.id)); + for (const assignment of next.assignments) { + if (interruptedIds.has(assignment.id)) assignment.status = "interrupted"; + } + next.lease = { + processId: this.processId, + activeParentSessionId: this.options.parentSessionId, + heartbeatAt: this.now(), + worktreeIdentity: next.workspace.root, + }; + next.parentSessionIds = [ + ...new Set([...next.parentSessionIds, this.options.parentSessionId]), + ]; + next.status = "waiting"; + if (!activeGate(next)) { + const gate: WorkflowGate = { + id: this.createId(), + kind: "recovery", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { + interruptedAssignmentIds: running.map( + (assignment) => assignment.id, + ), + potentiallyMutating: running.some( + (assignment) => assignment.mutates, + ), + }, + }; + openGate(next, gate); + } + }); + recovered.push(view(run)); + } + return recovered; + } + + private start(command: StartWorkflowCommand) { + const commandFingerprint = fingerprint(command); + const activeKey = `${this.options.store.root}\0${command.idempotencyKey}`; + const active = ACTIVE_STARTS.get(activeKey); + if (active) { + if (active.fingerprint !== commandFingerprint) { + return Promise.reject( + new Error("idempotency key is active for another start command"), + ); + } + return command.background + ? this.latestBackgroundView(active.published) + : active.operation; + } + let resolvePublished = (_view: WorkflowView) => {}; + let rejectPublished = (_error: unknown) => {}; + const published = new Promise((resolve, reject) => { + resolvePublished = resolve; + rejectPublished = reject; + }); + const operation = this.startOwned(command, resolvePublished); + void operation.catch(rejectPublished); + void published.catch(() => {}); + ACTIVE_STARTS.set(activeKey, { + fingerprint: commandFingerprint, + operation, + published, + }); + void operation + .finally(() => { + if (ACTIVE_STARTS.get(activeKey)?.operation === operation) { + ACTIVE_STARTS.delete(activeKey); + } + }) + .catch(() => {}); + return command.background ? published : operation; + } + + private async latestBackgroundView(published: Promise) { + const initial = await published; + try { + return view(await this.options.store.loadRun(initial.runId)); + } catch { + return initial; + } + } + + private startAnalysis(command: StartAnalysisWorkflowCommand) { + const commandFingerprint = fingerprint(command); + const activeKey = `${this.options.store.root}\0${command.idempotencyKey}`; + const active = ACTIVE_STARTS.get(activeKey); + if (active) { + if (active.fingerprint !== commandFingerprint) { + return Promise.reject( + new Error("idempotency key is active for another start command"), + ); + } + return active.operation; + } + const operation = this.startAnalysisOwned(command); + ACTIVE_STARTS.set(activeKey, { + fingerprint: commandFingerprint, + operation, + published: operation, + }); + void operation + .finally(() => { + if (ACTIVE_STARTS.get(activeKey)?.operation === operation) { + ACTIVE_STARTS.delete(activeKey); + } + }) + .catch(() => {}); + return operation; + } + + private async startAnalysisOwned(command: StartAnalysisWorkflowCommand) { + if (!this.options.scriptRuntime) { + throw new Error("Analysis scripts require the restricted script runtime"); + } + const commandFingerprint = fingerprint(command); + const existing = await this.options.store.getStart(command.idempotencyKey); + if (existing) { + this.validateExistingStart(existing, commandFingerprint); + if (existing.status === "completed") { + return existing.result as unknown as WorkflowView; + } + } + const runId = existing?.runId ?? this.createId(); + const reservation = await this.options.store.reserveStart( + command.idempotencyKey, + commandFingerprint, + runId, + ); + this.validateExistingStart(reservation.record, commandFingerprint); + let run: WorkflowRun; + try { + run = await this.options.store.loadRun(reservation.record.runId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + run = this.initialAnalysisRun(reservation.record.runId, command); + run = (await this.options.store.createRun(run)).run; + } + if (run.status !== "completed" && run.status !== "failed") { + run = await this.runWorkflowScript(run); + } + const result = view(run); + await this.options.store.completeStart(command.idempotencyKey, result); + return result; + } + + private async startOwned( + command: StartWorkflowCommand, + onPublished?: (view: WorkflowView) => void, + ) { + this.validateStart(command); + const commandFingerprint = fingerprint(command); + const existing = await this.options.store.getStart(command.idempotencyKey); + if (existing) { + this.validateExistingStart(existing, commandFingerprint); + if (existing.status === "completed") { + // SAFETY: completeStart only persists WorkflowView results produced by this engine. + const result = existing.result as unknown as WorkflowView; + onPublished?.(result); + return result; + } + } + + const initialFacts: WorkflowFacts = { + stage: "discover", + taskKind: "feature", + requestTerms: requestTerms(command.request), + candidatePaths: [], + languages: [], + tags: [], + riskTags: [], + }; + const preflightEligibility = this.preflightEligibility(initialFacts); + selectInitialDiscoveryProfile(this.options.profiles, initialFacts); + + let persistedRun: WorkflowRun | undefined; + if (existing?.status === "pending") { + try { + persistedRun = await this.options.store.loadRun(existing.runId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + let runId = existing?.runId ?? this.createId(); + let workspacePlan = persistedRun + ? this.workspacePlanForRun(persistedRun) + : await this.options.workspace.prepare({ + sourceRoot: this.options.sourceRoot, + configuredRoot: this.options.effectiveConfig.worktrees.root, + featureName: command.featureName, + runId, + }); + if (workspacePlan.branchExists) { + const owner = await this.options.store.findRunByBranch( + workspacePlan.branch, + ); + const resumable = + existing?.status === "pending" && owner?.runId === runId; + const uncertainPending = existing?.status === "pending" && !owner; + if (!resumable && !uncertainPending) { + if (owner) { + throw new Error( + `Branch ${workspacePlan.branch} already belongs to Workflow Run ${owner.runId}; continue it with inspect or decide`, + ); + } + throw new Error( + `Branch ${workspacePlan.branch} already exists; choose another descriptive feature name`, + ); + } + } + + const reservation = await this.options.store.reserveStart( + command.idempotencyKey, + commandFingerprint, + runId, + ); + this.validateExistingStart(reservation.record, commandFingerprint); + if (reservation.record.runId !== runId) { + runId = reservation.record.runId; + try { + persistedRun = await this.options.store.loadRun(runId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + persistedRun = undefined; + } + workspacePlan = persistedRun + ? this.workspacePlanForRun(persistedRun) + : await this.options.workspace.prepare({ + sourceRoot: this.options.sourceRoot, + configuredRoot: this.options.effectiveConfig.worktrees.root, + featureName: command.featureName, + runId, + }); + } + + { + let run: WorkflowRun; + let persistedBeforeThisAttempt = true; + try { + run = persistedRun ?? (await this.options.store.loadRun(runId)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + run = this.initialRun( + runId, + command, + workspacePlan, + preflightEligibility, + ); + const publication = await this.options.store.createRun(run); + persistedBeforeThisAttempt = !publication.created; + run = publication.run; + } + + onPublished?.(view(run)); + + if (persistedBeforeThisAttempt) { + const claimed = await this.claimPersistedStartRun(run); + if (!claimed) return view(run); + run = claimed; + } + + if (run.workspace.state === "creating" && persistedBeforeThisAttempt) { + const reconciled = await this.options.workspace.reconcile( + run.workspace, + run.lease?.worktreeIdentity, + ); + if (reconciled.state !== "creating") { + run = await this.persistWorkspaceOutcome(run, reconciled); + } + } + + if (run.workspace.state === "creating") { + let workspace: WorkflowRun["workspace"]; + try { + workspace = await this.options.workspace.create(workspacePlan); + } catch (error) { + workspace = { + ...run.workspace, + state: "creating", + error: error instanceof Error ? error.message : String(error), + }; + } + run = await this.persistWorkspaceOutcome(run, workspace); + } + + if (run.workspace.state === "ready" && run.status !== "failed") { + const discoverStatus = stage(run, "discover").status; + const designStatus = stage(run, "design").status; + if (discoverStatus === "pending" || discoverStatus === "running") { + run = await this.runDiscovery(run); + } else if ( + discoverStatus === "completed" && + (designStatus === "pending" || designStatus === "running") + ) { + run = await this.runDesign(run); + } + } + + const result = view(run); + await this.options.store.completeStart(command.idempotencyKey, result); + return result; + } + } + + private async claimPersistedStartRun(run: WorkflowRun) { + if ( + run.lease?.processId === this.processId && + run.lease.activeParentSessionId === this.options.parentSessionId + ) { + return run; + } + if ( + run.workspace.state === "creating" && + run.lease && + run.lease.processId !== this.processId && + this.isProcessAlive(run.lease.processId) + ) { + return undefined; + } + if (!this.leaseIsStale(run)) return undefined; + try { + return await this.transition(run, (next) => { + next.lease = { + processId: this.processId, + activeParentSessionId: this.options.parentSessionId, + heartbeatAt: this.now(), + worktreeIdentity: next.workspace.root, + }; + next.parentSessionIds = [ + ...new Set([...next.parentSessionIds, this.options.parentSessionId]), + ]; + }); + } catch (error) { + if ( + error instanceof Error && + /Stale workflow revision/.test(error.message) + ) { + return undefined; + } + throw error; + } + } + + private async persistWorkspaceOutcome( + run: WorkflowRun, + workspace: WorkflowRun["workspace"], + ) { + return this.transition(run, (next) => { + next.workspace = workspace; + const initialize = stage(next, "initialize"); + if (workspace.state === "ready") { + initialize.status = "completed"; + initialize.finishedAt = this.now(); + next.status = "running"; + next.error = undefined; + return; + } + initialize.status = workspace.state === "failed" ? "failed" : "waiting"; + next.status = "waiting"; + next.error = { + code: "workspace-recovery-required", + message: + workspace.error ?? "Workspace creation requires reconciliation", + stageId: initialize.id, + }; + if (!activeGate(next)) { + openGate(next, { + id: this.createId(), + kind: "recovery", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { + workspaceState: workspace.state, + workspaceError: workspace.error, + }, + }); + } + }); + } + + private validateExistingStart( + record: StartRecord, + commandFingerprint: string, + ) { + if (record.fingerprint !== commandFingerprint) { + throw new Error( + "idempotency key was already used with another start command", + ); + } + if (record.status === "completed" && !record.result) { + throw new Error("Completed start record is missing its result"); + } + } + + private initialAnalysisRun( + runId: string, + command: StartAnalysisWorkflowCommand, + ): WorkflowRun { + const now = this.now(); + const run = this.initialRun( + runId, + { + action: "start", + request: command.request, + featureName: "analysis-script", + idempotencyKey: command.idempotencyKey, + }, + { + sourceRoot: this.options.sourceRoot, + worktreeRoot: this.options.sourceRoot, + worktreePath: this.options.sourceRoot, + branch: "", + baseHead: "", + branchExists: false, + }, + [], + ); + run.mode = "analysis"; + run.featureName = undefined; + run.workspace = { + state: "not-required", + sourceRoot: this.options.sourceRoot, + root: this.options.sourceRoot, + }; + run.script = { + source: command.source, + ...(command.args !== undefined + ? { args: structuredClone(command.args) } + : {}), + activeToolNames: [...command.activeToolNames], + status: "pending", + stages: [], + executed: false, + }; + run.facts = { + ...run.facts, + taskKind: "analysis", + stage: "discover", + }; + for (const workflowStage of run.stages) { + workflowStage.status = workflowStage.kind === "discover" ? "running" : "pending"; + if (workflowStage.kind === "discover") workflowStage.startedAt = now; + else delete workflowStage.startedAt; + delete workflowStage.finishedAt; + } + return run; + } + + private initialRun( + runId: string, + command: StartWorkflowCommand, + workspacePlan: WorkspacePlan, + preflightEligibility: StageEligibilityEvidence[], + ): WorkflowRun { + const now = this.now(); + const stages: WorkflowStage[] = [ + "initialize", + "discover", + "design", + "prototype", + "implement", + "remediate", + "verify", + "review", + "complete", + ].map((kind) => ({ + id: `${runId}:${kind}`, + kind: kind as WorkflowStage["kind"], + status: kind === "initialize" ? "running" : "pending", + ...(kind === "initialize" ? { startedAt: now } : {}), + })); + return { + runId, + revision: 0, + mode: "feature", + ...(command.script + ? { + script: { + source: command.script.source, + ...(command.script.args !== undefined + ? { args: structuredClone(command.script.args) } + : {}), + insertionPoint: command.script.insertionPoint, + activeToolNames: [...command.script.activeToolNames], + status: "pending" as const, + stages: [], + executed: false, + }, + } + : {}), + status: "running", + background: command.background ?? false, + request: command.request.trim(), + featureName: command.featureName, + parentSessionIds: [this.options.parentSessionId], + lease: { + processId: this.processId, + activeParentSessionId: this.options.parentSessionId, + heartbeatAt: now, + worktreeIdentity: workspacePlan.worktreePath, + }, + workspace: { + state: "creating", + sourceRoot: workspacePlan.sourceRoot, + root: workspacePlan.worktreePath, + branch: workspacePlan.branch, + baseHead: workspacePlan.baseHead, + }, + effectiveConfig: { + ...structuredClone(this.options.effectiveConfig), + concurrency: { + ...structuredClone(this.options.effectiveConfig.concurrency), + default: + command.concurrency ?? + this.options.effectiveConfig.concurrency.default, + }, + }, + pinnedProfiles: structuredClone(this.options.profiles), + preflightEligibility: structuredClone(preflightEligibility), + facts: { + stage: "discover", + taskKind: "feature", + requestTerms: requestTerms(command.request), + candidatePaths: [], + languages: [], + tags: [], + riskTags: [], + }, + stages, + gates: [], + decisions: [], + assignments: [], + prototypeCandidates: [], + prototypeCleanupCompleted: false, + deliveries: [], + commandReceipts: [], + error: undefined, + planArtifact: undefined, + implementationContext: undefined, + uiImpact: undefined, + uiImpactEvidence: [], + affectedUiSurfaces: [], + mutatorProfileNames: [], + reviewCycles: 0, + verificationCycles: 0, + verificationEvidence: undefined, + reviewVerdict: undefined, + }; + } + + private async saveApprovedPlan( + run: WorkflowRun, + decision: Extract, + ) { + if (!run.planArtifact) throw new Error("Plan Approval requires a plan artifact"); + const currentPlan = await readVerifiedPlanArtifact(run.planArtifact); + const acceptedDecisions = [ + ...run.decisions.map((entry) => entry.value), + decision, + ]; + const contents = [ + currentPlan.trimEnd(), + "## Accepted Decisions", + "```json", + JSON.stringify(acceptedDecisions, null, 2), + "```", + "## Worktree", + `- Source: ${run.workspace.sourceRoot}`, + `- Path: ${run.workspace.root ?? "not ready"}`, + `- Branch: ${run.workspace.branch ?? "not ready"}`, + `- Base HEAD: ${run.workspace.baseHead ?? "unknown"}`, + "## UI Impact Evidence", + `- Accepted classification: ${decision.acceptedUiImpact}`, + `- Affected surfaces: ${run.affectedUiSurfaces.join(", ") || "none"}`, + ...run.uiImpactEvidence.map((evidence) => `- ${evidence}`), + `- Candidate paths: ${run.facts.candidatePaths.join(", ") || "none"}`, + `- Tags: ${run.facts.tags.join(", ") || "none"}`, + `- Risk tags: ${run.facts.riskTags.join(", ") || "none"}`, + ].join("\n\n"); + return this.options.store.savePlan( + run.runId, + run.planArtifact.revision + 1, + contents, + ); + } + + private async saveAcceptedPrototypePlan( + run: WorkflowRun, + decision: Extract, + ) { + if (!run.planArtifact) throw new Error("Prototype Acceptance requires a plan"); + const candidate = run.prototypeCandidates.find( + (entry) => + entry.id === decision.candidateId && + entry.revision === decision.candidateRevision, + ); + if (!candidate) throw new Error("Accepted prototype candidate is missing"); + const plan = await readVerifiedPlanArtifact(run.planArtifact); + const contents = [ + plan.trimEnd(), + "## Accepted Prototype", + `- Candidate: ${candidate.id} revision ${candidate.revision}`, + `- Route: ${candidate.route}`, + `- Structural direction: ${candidate.structuralRationale}`, + `- Evidence artifact: ${candidate.artifactPath}`, + `- Feedback: ${decision.feedback?.trim() || "none"}`, + ].join("\n\n"); + return this.options.store.savePlan( + run.runId, + run.planArtifact.revision + 1, + contents, + ); + } + + private prototypeCleanupAssignment(run: WorkflowRun): WorkflowAssignment { + const implementer = run.assignments.find( + (assignment) => assignment.role === "implement", + ); + if (!implementer) throw new Error("Prototype cleanup requires an implementer"); + const ownedPaths = [ + ...new Set( + run.assignments + .filter((assignment) => assignment.role === "prototype") + .flatMap((assignment) => assignment.ownedPaths), + ), + ]; + return { + id: this.createId(), + stageId: stage(run, "remediate").id, + role: "remediate", + profileName: implementer.profileName, + profileDigest: implementer.profileDigest, + selection: { + ...structuredClone(implementer.selection), + rationale: + "Remove prototype-only routes, variants, and switchers while preserving persisted prototype evidence.", + }, + status: "queued", + mutates: true, + ownedPaths, + }; + } + + private refinementAssignments(run: WorkflowRun) { + const prototype = run.assignments.find( + (assignment) => assignment.role === "prototype", + ); + if (!prototype) throw new Error("Prototype refinement requires a prototype profile"); + const roots = [ + ...new Set( + run.assignments + .filter((assignment) => assignment.role === "prototype") + .flatMap((assignment) => assignment.ownedPaths) + .map((ownedPath) => ownedPath.replace(/[/\\]candidate-\d+$/, "")), + ), + ]; + return Array.from({ length: 3 }, (_, index): WorkflowAssignment => ({ + ...structuredClone(prototype), + id: this.createId(), + status: "queued", + executionToken: undefined, + result: undefined, + error: undefined, + selection: { + ...structuredClone(prototype.selection), + rationale: `Refined candidate ${index + 1} must address accepted feedback and remain structurally distinct.`, + }, + ownedPaths: roots.map((root) => + path.join(root, `refinement-${run.revision + 1}-candidate-${index + 1}`), + ), + })); + } + + private openImplementationContext(run: WorkflowRun) { + if (!run.planArtifact) { + throw new Error("Implementation Context requires a persisted plan"); + } + if (run.implementationContext?.status === "waiting") return; + const gate: WorkflowGate = { + id: this.createId(), + kind: "implementation-context", + status: "open", + revision: run.revision + 1, + openedAt: this.now(), + payload: { + planRevision: run.planArtifact.revision, + planDigest: run.planArtifact.digest, + strategies: ["fresh-session", "smart-compact", "current-session"], + }, + }; + run.implementationContext = { + gateId: gate.id, + runRevision: run.revision + 1, + planRevision: run.planArtifact.revision, + preparationId: this.createId(), + activeParentSessionId: this.options.parentSessionId, + sourceParentSessionId: this.options.parentSessionId, + status: "waiting", + }; + openGate(run, gate); + run.status = "waiting"; + } + + private async runWorkflowScript( + initialRun: WorkflowRun, + insertionPoint?: FeatureScriptInsertionPoint, + ) { + let run = initialRun; + const script = run.script; + if ( + !script || + script.executed || + (run.mode === "feature" && script.insertionPoint !== insertionPoint) + ) { + return run; + } + if (!this.options.scriptRuntime) { + return this.failRun( + run, + "script-runtime-unavailable", + new Error("Custom scripts require the restricted script runtime"), + ); + } + + let stateQueue: Promise = Promise.resolve(); + const update = (mutate: (next: WorkflowRun) => void) => { + const operation = stateQueue.then(async () => { + run = await this.transition(run, mutate); + }); + stateQueue = operation.catch(() => {}); + return operation; + }; + const allowedReadTools = run.effectiveConfig.analysisTools.filter((tool) => + script.activeToolNames.includes(tool), + ); + const stageKind: WorkflowStage["kind"] = + run.mode === "analysis" + ? "discover" + : insertionPoint === "before-review" + ? "review" + : "design"; + const controller = new AbortController(); + let resolveSettled = () => {}; + const activeScript = { + controller, + settled: new Promise((resolve) => { + resolveSettled = resolve; + }), + resolve: () => resolveSettled(), + }; + ACTIVE_SCRIPT_EXECUTIONS.set(run.runId, activeScript); + const activeScriptAssignments = new Set>(); + + try { + await update((next) => { + if (!next.script) throw new Error("Script state is missing"); + next.script.status = "running"; + }); + const stageUpdates: Promise[] = []; + const result = await this.options.scriptRuntime.run({ + source: script.source, + args: script.args, + cwd: run.workspace.root ?? run.workspace.sourceRoot, + signal: controller.signal, + onStage: (title) => { + stageUpdates.push( + update((next) => { + if (!next.script) throw new Error("Script state is missing"); + next.script.stages.push(title); + }), + ); + }, + onAssign: (requestedRole, prompt, signal, requestId) => { + const operation = (async () => { + if ( + requestedRole !== "discover" && + requestedRole !== "design" && + requestedRole !== "verify" && + requestedRole !== "review" + ) { + throw new Error( + `Script Assignment role ${requestedRole} is not read-only or supported`, + ); + } + const role = requestedRole as ProfileRole; + const requestFingerprint = fingerprint({ role, prompt }); + await stateQueue; + const reusable = run.assignments.find( + (assignment) => + assignment.scriptRequestId === requestId && + assignment.scriptRequestFingerprint === requestFingerprint && + assignment.status === "succeeded", + ); + if (reusable) { + return { + ok: true, + output: + typeof reusable.result === "string" + ? reusable.result + : JSON.stringify(reusable.result), + structured: structuredClone(reusable.result), + }; + } + const selected = selectionEvidence( + run.pinnedProfiles, + { ...run.facts, stage: role }, + role, + ); + const assignmentId = this.createId(); + await update((next) => { + if ( + next.assignments.length >= next.effectiveConfig.maxAssignmentsPerRun + ) { + throw new Error("Workflow Run exceeded its Assignment limit"); + } + next.assignments.push({ + id: assignmentId, + stageId: stage(next, stageKind).id, + role, + profileName: selected.profile.name, + profileDigest: selected.profile.digest, + selection: { + ...structuredClone(selected.selection), + rationale: `Custom read-only script Assignment: ${prompt.slice(0, 160)}`, + }, + status: "queued", + mutates: false, + ownedPaths: [], + scriptRequestId: requestId, + scriptRequestFingerprint: requestFingerprint, + }); + }); + const markRunning = () => + update((next) => { + const assignment = next.assignments.find( + (candidate) => candidate.id === assignmentId, + ); + if (!assignment || assignment.status !== "queued") { + throw new Error(`Script Assignment ${assignmentId} is not queued`); + } + assignment.status = "running"; + }); + if (!this.options.executor.reservesCapacity) await markRunning(); + try { + const executionResult = await this.options.executor.run({ + runId: run.runId, + role, + request: prompt, + profile: selected.profile, + facts: { ...structuredClone(run.facts), stage: role }, + decisions: structuredClone(run.decisions), + cwd: run.workspace.root ?? run.workspace.sourceRoot, + signal, + mutates: false, + ownedPaths: [], + captureGitState: true, + allowedReadTools, + runConcurrency: run.effectiveConfig.concurrency.default, + ...(this.options.executor.reservesCapacity + ? { onReserved: markRunning } + : {}), + }); + const metadata = isAssignmentExecutionResult(executionResult) + ? executionResult.metadata + : undefined; + const output = isAssignmentExecutionResult(executionResult) + ? executionResult.output + : executionResult; + const transcriptArtifact = metadata + ? await this.options.store.saveAssignmentTranscript( + run.runId, + assignmentId, + metadata.transcript, + ) + : undefined; + await update((next) => { + const assignment = next.assignments.find( + (candidate) => candidate.id === assignmentId, + ); + if (!assignment || assignment.status !== "running") { + throw new Error(`Script Assignment ${assignmentId} lost its fence`); + } + assignment.status = "succeeded"; + assignment.result = structuredClone(output); + if (metadata) { + assignment.childSessionId = metadata.childSessionId; + assignment.effectiveProvider = metadata.provider; + assignment.effectiveModel = metadata.model; + assignment.effectiveEffort = metadata.effort; + assignment.contextCapacity = metadata.contextCapacity; + assignment.gitBefore = metadata.gitBefore; + assignment.gitAfter = metadata.gitAfter; + } + if (transcriptArtifact) { + assignment.transcriptArtifact = transcriptArtifact; + } + }); + return { + ok: true, + output: + typeof output === "string" ? output : JSON.stringify(output), + structured: output, + }; + } catch (error) { + await update((next) => { + const assignment = next.assignments.find( + (candidate) => candidate.id === assignmentId, + ); + if (assignment) { + assignment.status = "failed"; + assignment.error = + error instanceof Error ? error.message : String(error); + } + }); + throw error; + } + })(); + activeScriptAssignments.add( + operation.then( + () => {}, + () => {}, + ), + ); + return operation; + }, + }); + await Promise.allSettled([...activeScriptAssignments]); + await Promise.all(stageUpdates); + await update((next) => { + if (!next.script) throw new Error("Script state is missing"); + next.script.status = "completed"; + next.script.executed = true; + next.script.result = structuredClone(result); + if (next.mode === "analysis") { + next.status = "completed"; + const discover = stage(next, "discover"); + discover.status = "completed"; + discover.finishedAt = this.now(); + const complete = stage(next, "complete"); + complete.status = "completed"; + complete.startedAt ??= this.now(); + complete.finishedAt = this.now(); + } + }); + return run; + } catch (error) { + await Promise.allSettled([...activeScriptAssignments]); + await update((next) => { + if (next.script) { + next.script.status = "failed"; + next.script.error = error instanceof Error ? error.message : String(error); + } + next.status = "failed"; + next.error = { + code: "script-execution-failed", + message: error instanceof Error ? error.message : String(error), + }; + }).catch(() => {}); + return run; + } finally { + if (ACTIVE_SCRIPT_EXECUTIONS.get(run.runId) === activeScript) { + ACTIVE_SCRIPT_EXECUTIONS.delete(run.runId); + } + activeScript.resolve(); + } + } + + private async runDiscovery(run: WorkflowRun) { + const selected = selectionEvidence( + run.pinnedProfiles, + { ...run.facts, stage: "discover" }, + "discover", + ); + const execution = await this.executeOrReuseAssignment( + run, + "discover", + selected, + ); + run = execution.run; + if (!execution.output) return run; + let output: DiscoveryOutput; + try { + output = discoveryOutput(execution.output); + } catch (error) { + return this.failRun(run, "invalid-discovery-output", error); + } + run = await this.transition(run, (next) => { + next.facts = { + ...next.facts, + stage: "design", + candidatePaths: output.candidatePaths, + languages: output.languages, + tags: output.tags, + riskTags: output.riskTags, + }; + const discover = stage(next, "discover"); + discover.output = output; + if (output.questions.length > 0) { + discover.status = "waiting"; + next.status = "waiting"; + const gate: WorkflowGate = { + id: this.createId(), + kind: "questions", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { questions: output.questions }, + }; + openGate(next, gate); + } else { + discover.status = "completed"; + discover.finishedAt = this.now(); + } + }); + if (output.questions.length > 0) return run; + run = await this.runWorkflowScript(run, "before-design"); + return run.status === "failed" ? run : this.runDesign(run); + } + + private plannerCandidates(run: WorkflowRun) { + const roles: ProfileRole[] = ["prototype", "implement", "verify", "review"]; + return roles.map((role): StageEligibilityEvidence => { + const candidates = eligibleProfiles( + run.pinnedProfiles, + { ...run.facts, stage: role }, + role, + ).map((evaluation) => ({ + profileName: evaluation.profile.name, + profileDigest: evaluation.profile.digest, + eligible: evaluation.eligible, + reasons: [...evaluation.reasons], + specificity: evaluation.specificity, + })); + return { role, candidates }; + }); + } + + private buildPlannedAssignments(run: WorkflowRun, output: DesignOutput) { + const candidates = this.plannerCandidates(run); + const required: PlannedAssignmentOutput["role"][] = [ + "implement", + "verify", + "review", + ...(output.uiImpact === "non-trivial" ? (["prototype"] as const) : []), + ]; + for (const role of required) { + if (output.assignments.filter((assignment) => assignment.role === role).length !== 1) { + throw new Error(`Design must select exactly one ${role} Assignment`); + } + } + if (new Set(output.assignments.map((assignment) => assignment.role)).size !== output.assignments.length) { + throw new Error("Design Assignment roles must be unique"); + } + if ( + output.uiImpact !== "non-trivial" && + output.assignments.some((assignment) => assignment.role === "prototype") + ) { + throw new Error("Prototype Assignment requires non-trivial UI impact"); + } + + const planned = output.assignments.flatMap((selection): WorkflowAssignment[] => { + const evidence = candidates.find((entry) => entry.role === selection.role); + const selected = evidence?.candidates.find( + (candidate) => + candidate.eligible && candidate.profileName === selection.profileName, + ); + if (!evidence || !selected) { + throw new Error( + `Planner selected ineligible ${selection.role} profile ${selection.profileName}`, + ); + } + const profile = run.pinnedProfiles.find( + (candidate) => + candidate.name === selected.profileName && + candidate.digest === selected.profileDigest, + ); + if (!profile) throw new Error(`Pinned profile ${selected.profileName} is missing`); + const shouldMutate = + selection.role === "prototype" || selection.role === "implement"; + if (selection.mutates !== shouldMutate) { + throw new Error(`${selection.role} Assignment mutation policy is invalid`); + } + if (selection.mutates && selection.ownedPaths.length === 0) { + throw new Error(`${selection.role} Assignment requires owned paths`); + } + const candidateCount = selection.role === "prototype" ? 3 : 1; + return Array.from({ length: candidateCount }, (_, index) => ({ + id: this.createId(), + stageId: stage(run, selection.role).id, + role: selection.role, + profileName: profile.name, + profileDigest: profile.digest, + selection: { + eligibleProfiles: structuredClone(evidence.candidates), + selectedProfileName: profile.name, + rationale: + selection.role === "prototype" + ? `${selection.rationale} Candidate ${index + 1} must be structurally distinct, responsive, and browser-validated.` + : selection.rationale, + }, + status: "queued" as const, + mutates: selection.mutates, + ownedPaths: + selection.role === "prototype" + ? selection.ownedPaths.map((ownedPath) => + path.join(ownedPath, `candidate-${index + 1}`), + ) + : [...selection.ownedPaths], + })); + }); + const mutators = new Set( + planned.filter((assignment) => assignment.mutates).map((assignment) => assignment.profileName), + ); + const reviewer = planned.find((assignment) => assignment.role === "review"); + if (!reviewer || mutators.has(reviewer.profileName)) { + throw new Error("Planner must select an independent review profile"); + } + return planned; + } + + private async runDesign(run: WorkflowRun, forceNewAssignment = false) { + const selected = selectionEvidence( + run.pinnedProfiles, + { ...run.facts, stage: "design" }, + "design", + ); + const execution = await this.executeOrReuseAssignment( + run, + "design", + selected, + forceNewAssignment, + ); + run = execution.run; + if (!execution.output) return run; + let output: DesignOutput; + let plannedAssignments: WorkflowAssignment[]; + let planArtifact: NonNullable; + try { + output = designOutput(execution.output); + const uiPolicy = enforceUiImpact({ + plannerImpact: output.uiImpact, + plannerEvidence: output.uiImpactEvidence, + affectedSurfaces: output.affectedUiSurfaces, + facts: run.facts, + }); + output = { + ...output, + uiImpact: uiPolicy.impact, + uiImpactEvidence: uiPolicy.evidence, + affectedUiSurfaces: uiPolicy.affectedSurfaces, + }; + plannedAssignments = this.buildPlannedAssignments(run, output); + const planDocument = [ + output.plan, + "## Acceptance Criteria", + ...output.acceptanceCriteria.map((criterion) => `- ${criterion}`), + "## UI Impact", + `- Classification: ${output.uiImpact}`, + `- Affected surfaces: ${output.affectedUiSurfaces.join(", ") || "none"}`, + ...output.uiImpactEvidence.map((evidence) => `- ${evidence}`), + "## Planned Assignments", + ...output.assignments.map( + (assignment) => + `- ${assignment.role}: ${assignment.profileName} owns ${assignment.ownedPaths.join(", ") || "no mutable paths"}`, + ), + "## Verification Commands", + ...output.verificationCommands.map( + (command) => + `- ${command.executable} ${command.args.join(" ")} (${command.kind})`, + ), + ].join("\n\n"); + planArtifact = await this.options.store.savePlan( + run.runId, + run.revision + 1, + planDocument, + ); + } catch (error) { + return this.failRun(run, "invalid-design-output", error); + } + run = await this.transition(run, (next) => { + next.planArtifact = planArtifact; + next.uiImpact = output.uiImpact; + next.uiImpactEvidence = [...output.uiImpactEvidence]; + next.affectedUiSurfaces = [...output.affectedUiSurfaces]; + next.verificationEvidence = { + commands: output.verificationCommands.map((command) => ({ + id: this.createId(), + ...structuredClone(command), + status: "pending", + })), + focusedBehavioralTests: false, + verifierSucceeded: false, + }; + next.status = "waiting"; + const design = stage(next, "design"); + design.status = "waiting"; + design.output = output; + const plannedRoles: ProfileRole[] = [ + "prototype", + "implement", + "verify", + "review", + ]; + next.assignments = next.assignments.filter( + (assignment) => + assignment.role === "remediate" || + !plannedRoles.includes(assignment.role) || + assignment.status !== "queued", + ); + next.assignments.push(...structuredClone(plannedAssignments)); + const gate: WorkflowGate = { + id: this.createId(), + kind: "plan-approval", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { + planArtifact, + uiImpact: output.uiImpact, + uiImpactEvidence: output.uiImpactEvidence, + affectedUiSurfaces: output.affectedUiSurfaces, + }, + }; + openGate(next, gate); + }); + return this.runWorkflowScript(run, "after-design"); + } + + private async executeOrReuseAssignment( + run: WorkflowRun, + role: "discover" | "design", + selected: ReturnType, + forceNewAssignment = false, + ) { + const stageId = stage(run, role).id; + const existing = [...run.assignments] + .reverse() + .find( + (assignment) => + assignment.stageId === stageId && assignment.role === role, + ); + if (existing && !forceNewAssignment) { + return { + run, + output: existing.status === "succeeded" ? existing.result : undefined, + }; + } + return this.runAssignment(run, role, selected); + } + + private async assignmentEvidence( + run: WorkflowRun, + role: ProfileRole | "remediate", + ) { + if (role !== "verify" && role !== "review") return {}; + if (!run.planArtifact) throw new Error(`${role} requires an approved plan`); + const approvedPlan = await readVerifiedPlanArtifact(run.planArtifact); + if (role === "verify") { + return { + approvedPlan, + verificationEvidence: structuredClone(run.verificationEvidence), + }; + } + if (!this.options.reviewDiff) { + throw new Error("Review requires the read-only Git diff provider"); + } + return { + approvedPlan, + verificationEvidence: structuredClone(run.verificationEvidence), + reviewDiff: await this.options.reviewDiff.read( + run.workspace.root ?? run.workspace.sourceRoot, + run.workspace.baseHead, + ), + }; + } + + private async runAssignment( + run: WorkflowRun, + role: ProfileRole | "remediate", + selected: ReturnType, + planned?: WorkflowAssignment, + ) { + const assignmentId = planned?.id ?? this.createId(); + const executionToken = this.createId(); + const abortController = new AbortController(); + let resolveSettled = () => {}; + let resolveResumeAfterAbort = () => {}; + const activeExecution: ActiveAssignmentExecution = { + runId: run.runId, + assignmentId, + controller: abortController, + settled: new Promise((resolve) => { + resolveSettled = resolve; + }), + resolveSettled: () => resolveSettled(), + resumeAfterAbort: new Promise((resolve) => { + resolveResumeAfterAbort = resolve; + }), + resolveResumeAfterAbort: () => resolveResumeAfterAbort(), + }; + ACTIVE_ASSIGNMENT_EXECUTIONS.set(executionToken, activeExecution); + try { + await this.options.store.createAssignmentLease(run.runId, assignmentId, { + token: executionToken, + processId: this.processId, + heartbeatAt: this.now(), + }); + run = await this.transition(run, (next) => { + next.status = "running"; + const targetStage = stage(next, role); + targetStage.status = "running"; + targetStage.startedAt ??= this.now(); + if (planned) { + const assignment = next.assignments.find( + (candidate) => candidate.id === assignmentId, + ); + if (!assignment || assignment.status !== "queued") { + throw new Error(`Planned Assignment ${assignmentId} is not queued`); + } + assignment.executionToken = executionToken; + if (assignment.mutates) { + next.mutatorProfileNames = [ + ...new Set([...next.mutatorProfileNames, assignment.profileName]), + ]; + } + } else { + next.assignments.push({ + id: assignmentId, + stageId: targetStage.id, + role, + profileName: selected.profile.name, + profileDigest: selected.profile.digest, + selection: selected.selection, + status: "queued", + mutates: false, + ownedPaths: [], + executionToken, + }); + } + }); + } catch (error) { + await this.options.store + .releaseAssignmentLease(run.runId, assignmentId, executionToken) + .catch(() => {}); + ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken); + activeExecution.resolveSettled(); + activeExecution.resolveResumeAfterAbort(); + throw error; + } + + ACTIVE_ASSIGNMENT_TOKENS.add(executionToken); + const stopHeartbeat = this.startAssignmentHeartbeat( + run.runId, + assignmentId, + executionToken, + ); + const markRunning = async () => { + run = await this.transition(run, (next) => { + const assignment = next.assignments.find( + (item) => item.id === assignmentId, + ); + if (!assignment) { + throw new Error(`Assignment ${assignmentId} is missing`); + } + assignment.status = "running"; + }); + }; + if (!this.options.executor.reservesCapacity) await markRunning(); + + try { + let output: unknown; + let metadata: AssignmentExecutionMetadata | undefined; + let transcriptArtifact: WorkflowAssignment["transcriptArtifact"]; + try { + const evidence = await this.assignmentEvidence(run, role); + const executionResult = await this.options.executor.run({ + runId: run.runId, + role, + request: planned + ? `${run.request}\n\nAssignment instructions: ${planned.selection.rationale}\nOwned paths: ${planned.ownedPaths.join(", ")}` + : run.request, + profile: selected.profile, + facts: structuredClone(run.facts), + decisions: structuredClone(run.decisions), + ...(role === "design" + ? { plannerCandidates: this.plannerCandidates(run) } + : {}), + cwd: run.workspace.root ?? run.workspace.sourceRoot, + signal: abortController.signal, + mutates: planned?.mutates ?? false, + ownedPaths: planned?.ownedPaths ?? [], + captureGitState: role === "review", + runConcurrency: run.effectiveConfig.concurrency.default, + ...evidence, + ...(this.options.executor.reservesCapacity + ? { onReserved: markRunning } + : {}), + }); + metadata = isAssignmentExecutionResult(executionResult) + ? executionResult.metadata + : undefined; + output = isAssignmentExecutionResult(executionResult) + ? executionResult.output + : executionResult; + transcriptArtifact = metadata + ? await this.options.store.saveAssignmentTranscript( + run.runId, + assignmentId, + metadata.transcript, + ) + : undefined; + activeExecution.metadata = metadata; + activeExecution.transcriptArtifact = transcriptArtifact; + } catch (error) { + metadata = isAssignmentExecutionError(error) + ? error.assignmentMetadata + : undefined; + transcriptArtifact = metadata + ? await this.options.store.saveAssignmentTranscript( + run.runId, + assignmentId, + metadata.transcript, + ) + : undefined; + activeExecution.metadata = metadata; + activeExecution.transcriptArtifact = transcriptArtifact; + await stopHeartbeat(); + await this.assertAssignmentLease( + run.runId, + assignmentId, + executionToken, + ); + if (abortController.signal.aborted) { + activeExecution.resolveSettled(); + await activeExecution.resumeAfterAbort; + run = await this.options.store.loadRun(run.runId); + return { run, output: undefined }; + } + run = await this.transition(run, (next) => { + const assignment = next.assignments.find( + (item) => item.id === assignmentId, + ); + if (assignment) { + assignment.status = "failed"; + assignment.error = + error instanceof Error ? error.message : String(error); + applyExecutionMetadata(assignment, metadata, transcriptArtifact); + } + const targetStage = stage(next, role); + targetStage.status = "failed"; + targetStage.finishedAt = this.now(); + const message = error instanceof Error ? error.message : String(error); + if (planned?.mutates || role === "review") { + next.status = "waiting"; + next.error = { + code: "mutation-recovery-required", + message, + stageId: targetStage.id, + assignmentId, + }; + if (!activeGate(next)) { + openGate(next, { + id: this.createId(), + kind: "recovery", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { + interruptedAssignmentIds: [assignmentId], + potentiallyMutating: true, + }, + }); + } + } else { + next.status = "failed"; + next.error = { + code: "assignment-failed", + message, + stageId: targetStage.id, + assignmentId, + }; + } + }); + return { run, output: undefined }; + } + + await stopHeartbeat(); + await this.assertAssignmentLease(run.runId, assignmentId, executionToken); + run = await this.transition(run, (next) => { + const assignment = next.assignments.find( + (item) => item.id === assignmentId, + ); + if (!assignment) + throw new Error(`Assignment ${assignmentId} is missing`); + assignment.status = "succeeded"; + assignment.result = output; + applyExecutionMetadata(assignment, metadata, transcriptArtifact); + }); + const assignment = run.assignments.find( + (item) => item.id === assignmentId, + ); + if (!assignment) throw new Error(`Assignment ${assignmentId} is missing`); + await this.options.afterAssignmentPersist?.( + structuredClone(assignment), + run, + ); + return { run, output }; + } finally { + abortController.abort(); + await stopHeartbeat().catch(() => {}); + ACTIVE_ASSIGNMENT_TOKENS.delete(executionToken); + ACTIVE_ASSIGNMENT_EXECUTIONS.delete(executionToken); + await this.options.store + .releaseAssignmentLease(run.runId, assignmentId, executionToken) + .catch(() => {}); + activeExecution.resolveSettled(); + } + } + + private startAssignmentHeartbeat( + runId: string, + assignmentId: string, + executionToken: string, + ) { + let stopped = false; + let heartbeatError: unknown; + let pending = Promise.resolve(); + const timer = setInterval(() => { + pending = pending.then(async () => { + const owned = await this.options.store.heartbeatAssignmentLease( + runId, + assignmentId, + executionToken, + this.now(), + ); + if (!owned) + throw new Error(`Assignment ${assignmentId} lost its lease`); + }); + pending.catch((error) => { + heartbeatError ??= error; + }); + }, this.assignmentHeartbeatMs); + timer.unref?.(); + + return async () => { + if (!stopped) { + stopped = true; + clearInterval(timer); + } + await pending.catch(() => {}); + if (heartbeatError) throw heartbeatError; + }; + } + + private async assertAssignmentLease( + runId: string, + assignmentId: string, + executionToken: string, + ) { + const lease = await this.options.store.loadAssignmentLease( + runId, + assignmentId, + ); + if (!lease || lease.token !== executionToken) { + throw new Error(`Assignment ${assignmentId} lost its execution lease`); + } + } + + private async decide(run: WorkflowRun, command: DecideWorkflowCommand) { + const duplicate = this.commandReceipt(run, command); + if (duplicate) { + if (duplicate.status === "completed" && duplicate.result) { + return duplicate.result as WorkflowView; + } + return this.resumePendingDecision(run, duplicate); + } + if (command.expectedRevision !== run.revision) { + throw new Error( + `Stale workflow revision ${command.expectedRevision}; current revision is ${run.revision}`, + ); + } + if (run.status !== "waiting") { + throw new Error(`Workflow Run must be waiting to decide a Gate`); + } + const openGates = run.gates.filter( + (candidate) => candidate.status === "open", + ); + if (openGates.length !== 1 || openGates[0]?.id !== command.gateId) { + throw new Error("Decision must target the single active Gate"); + } + const gate = openGates[0]; + if (gate.revision > run.revision) + throw new Error("Gate revision is invalid"); + legalDecision(gate, command.decision); + if ( + gate.kind === "questions" && + stage(run, "discover").status !== "waiting" + ) { + throw new Error("Questions Gate requires a waiting Discover stage"); + } + if ( + gate.kind === "plan-approval" && + stage(run, "design").status !== "waiting" + ) { + throw new Error("Plan Approval Gate requires a waiting Design stage"); + } + if (gate.kind === "implementation-context") { + const context = run.implementationContext; + if (!context || context.gateId !== gate.id) { + throw new Error("Implementation Context state does not match the Gate"); + } + if (!run.planArtifact || context.planRevision !== run.planArtifact.revision) { + throw new Error("Implementation Context plan revision is stale"); + } + if ( + run.assignments.some( + (assignment) => + assignment.status === "running" || + (assignment.status === "queued" && Boolean(assignment.executionToken)), + ) + ) { + throw new Error("Implementation Context cannot change while an Assignment is active"); + } + } + validateDecisionPayload(gate, command.decision); + if ( + command.decision.kind === "recover" && + command.decision.resolution === "retry" && + run.workspace.state !== "ready" && + run.lease && + run.lease.processId !== this.processId && + this.isProcessAlive(run.lease.processId) + ) { + throw new Error("Workspace creation is still owned by a live process"); + } + if ( + command.decision.kind === "approve-plan" && + command.decision.acceptedUiImpact !== run.uiImpact + ) { + throw new Error("Approved UI impact does not match the current plan"); + } + if (command.decision.kind === "report-context-preparation") { + const context = run.implementationContext; + if ( + !context || + context.gateId !== command.gateId || + context.preparationId !== command.decision.preparationId || + context.planRevision !== command.decision.planRevision || + context.strategy !== "smart-compact" || + context.status !== "smart-compact-pending" + ) { + throw new Error("Context preparation report does not match the pending intent"); + } + if (command.decision.outcome === "unavailable") { + if (!command.activeToolNames) { + throw new Error("activeToolNames are required for an unavailable report"); + } + if (command.activeToolNames.includes("smart_compact")) { + throw new Error("smart_compact is active and cannot be reported unavailable"); + } + } + } + + const approvedPlanArtifact = + command.decision.kind === "approve-plan" + ? await this.saveApprovedPlan(run, command.decision) + : undefined; + const acceptedPrototypePlan = + command.decision.kind === "accept-prototype" + ? await this.saveAcceptedPrototypePlan(run, command.decision) + : undefined; + const cleanupAssignment = + command.decision.kind === "accept-prototype" + ? this.prototypeCleanupAssignment(run) + : undefined; + const refinedAssignments = + command.decision.kind === "refine-prototypes" + ? this.refinementAssignments(run) + : undefined; + const commandFingerprint = fingerprint(command); + run = await this.transition(run, (next) => { + const nextGate = next.gates.find( + (candidate) => candidate.id === command.gateId, + ); + if (!nextGate || nextGate.status !== "open") + throw new Error("Gate is not open"); + const keepsGateOpen = + (command.decision.kind === "recover" && + command.decision.resolution === "inspect") || + (command.decision.kind === "select-implementation-context" && + command.decision.strategy !== "current-session") || + (command.decision.kind === "report-context-preparation" && + (command.decision.outcome === "unavailable" || + command.decision.outcome === "failed")); + if (!keepsGateOpen) { + nextGate.status = + command.decision.kind === "revise-plan" || + command.decision.kind === "refine-prototypes" + ? "rejected" + : "satisfied"; + } + next.decisions.push({ + gateId: command.gateId, + revision: next.revision + 1, + idempotencyKey: command.idempotencyKey, + decidedAt: this.now(), + value: structuredClone(command.decision), + }); + next.commandReceipts.push({ + key: command.idempotencyKey, + action: "decide", + fingerprint: commandFingerprint, + status: "pending", + acceptedRevision: next.revision + 1, + continuation: decisionContinuation(gate.kind, command.decision), + }); + next.status = "running"; + if (gate.kind === "questions") { + const discover = stage(next, "discover"); + discover.status = "completed"; + discover.finishedAt = this.now(); + } else if ( + gate.kind === "plan-approval" && + command.decision.kind === "approve-plan" + ) { + const design = stage(next, "design"); + design.status = "completed"; + design.finishedAt = this.now(); + if (!approvedPlanArtifact) { + throw new Error("Approved plan artifact is missing"); + } + next.planArtifact = approvedPlanArtifact; + if (next.uiImpact === "non-trivial") { + const prototype = stage(next, "prototype"); + prototype.status = "pending"; + prototype.startedAt = undefined; + prototype.finishedAt = undefined; + next.status = "running"; + } else { + this.openImplementationContext(next); + } + } else if ( + gate.kind === "prototype-acceptance" && + command.decision.kind === "accept-prototype" + ) { + if (!acceptedPrototypePlan || !cleanupAssignment) { + throw new Error("Accepted prototype continuation is incomplete"); + } + next.planArtifact = acceptedPrototypePlan; + next.assignments.push(structuredClone(cleanupAssignment)); + const prototype = stage(next, "prototype"); + prototype.status = "completed"; + prototype.finishedAt = this.now(); + this.openImplementationContext(next); + } else if ( + gate.kind === "prototype-acceptance" && + command.decision.kind === "refine-prototypes" + ) { + if (!refinedAssignments) { + throw new Error("Prototype refinement Assignments are missing"); + } + next.assignments.push(...structuredClone(refinedAssignments)); + const prototype = stage(next, "prototype"); + prototype.status = "pending"; + prototype.startedAt = undefined; + prototype.finishedAt = undefined; + next.status = "running"; + } else if ( + gate.kind === "implementation-context" && + command.decision.kind === "select-implementation-context" + ) { + const context = next.implementationContext; + if (!context) throw new Error("Implementation Context state is missing"); + context.strategy = command.decision.strategy; + context.runRevision = next.revision + 1; + context.activeParentSessionId = this.options.parentSessionId; + context.sourceParentSessionId = this.options.parentSessionId; + context.error = undefined; + context.outcome = undefined; + if (command.decision.strategy === "fresh-session") { + context.status = "handoff-pending"; + context.method = "fresh-session"; + next.status = "waiting"; + } else if (command.decision.strategy === "smart-compact") { + context.status = "smart-compact-pending"; + context.method = "smart_compact"; + next.status = "waiting"; + } else { + context.status = "ready"; + context.method = "unchanged"; + context.outcome = "no-op"; + next.status = "running"; + } + } else if ( + gate.kind === "implementation-context" && + command.decision.kind === "report-context-preparation" + ) { + const context = next.implementationContext; + if (!context) throw new Error("Implementation Context state is missing"); + context.runRevision = next.revision + 1; + context.outcome = command.decision.outcome; + if (command.decision.outcome === "unavailable") { + context.status = "normal-compact-pending"; + next.status = "waiting"; + } else if (command.decision.outcome === "failed") { + context.status = "failed"; + context.error = command.decision.error; + next.status = "waiting"; + } else { + context.status = "ready"; + context.error = undefined; + next.status = "running"; + } + } + }); + + const receipt = run.commandReceipts.find( + (candidate) => candidate.key === command.idempotencyKey, + ); + if (!receipt) throw new Error("Persisted command receipt is missing"); + return this.resumePendingDecision(run, receipt); + } + + private profileForAssignment(run: WorkflowRun, assignment: WorkflowAssignment) { + const profile = run.pinnedProfiles.find( + (candidate) => + candidate.name === assignment.profileName && + candidate.digest === assignment.profileDigest, + ); + if (!profile) { + throw new Error(`Pinned profile ${assignment.profileName} is missing`); + } + return { profile, selection: assignment.selection }; + } + + private queuedAssignment(run: WorkflowRun, role: WorkflowAssignment["role"]) { + return run.assignments.find( + (assignment) => assignment.role === role && assignment.status === "queued", + ); + } + + private succeededAssignment( + run: WorkflowRun, + role: WorkflowAssignment["role"], + ) { + return [...run.assignments] + .reverse() + .find( + (assignment) => + assignment.role === role && + assignment.status === "succeeded" && + assignment.result !== undefined, + ); + } + + private async runPlannedAssignment( + run: WorkflowRun, + assignment: WorkflowAssignment, + ) { + return this.runAssignment( + run, + assignment.role, + this.profileForAssignment(run, assignment), + assignment, + ); + } + + private async markStageCompleted( + run: WorkflowRun, + kind: WorkflowStage["kind"], + output?: unknown, + ) { + return this.transition(run, (next) => { + const target = stage(next, kind); + target.status = "completed"; + target.finishedAt = this.now(); + if (output !== undefined) target.output = output; + }); + } + + private async openLifecycleRecovery( + run: WorkflowRun, + message: string, + assignmentIds: string[] = [], + ) { + if (activeGate(run)?.kind === "recovery") return run; + return this.transition(run, (next) => { + next.status = "waiting"; + next.error = { code: "lifecycle-recovery-required", message }; + openGate(next, { + id: this.createId(), + kind: "recovery", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { + interruptedAssignmentIds: assignmentIds, + message, + }, + }); + }); + } + + private async runVerificationCommands(run: WorkflowRun) { + const evidence = run.verificationEvidence; + if (!evidence) throw new Error("Verification evidence is missing"); + if (!this.options.hostCommands && evidence.commands.length > 0) { + return this.failRun( + run, + "host-command-executor-missing", + new Error("Verification commands require the host command executor"), + ); + } + for (const current of evidence.commands) { + const command = run.verificationEvidence?.commands.find( + (candidate) => candidate.id === current.id, + ); + if ( + !command || + command.status === "succeeded" || + command.status === "superseded" + ) { + continue; + } + if (command.status === "running" || command.status === "interrupted") { + return this.openLifecycleRecovery( + run, + `Verification command ${command.id} was interrupted and will not be replayed automatically`, + ); + } + run = await this.transition(run, (next) => { + const pending = next.verificationEvidence?.commands.find( + (candidate) => candidate.id === command.id, + ); + if (!pending || (pending.status !== "pending" && pending.status !== "failed")) { + throw new Error(`Verification command ${command.id} is not runnable`); + } + pending.status = "running"; + }); + let safetyFailure: WorkspaceSafetyError | undefined; + try { + const executed = await this.options.hostCommands!.run({ + worktreeRoot: run.workspace.root ?? run.workspace.sourceRoot, + ownedPaths: command.ownedPaths, + executable: command.executable, + args: command.args, + }); + run = await this.transition(run, (next) => { + const persisted = next.verificationEvidence?.commands.find( + (candidate) => candidate.id === command.id, + ); + if (!persisted) throw new Error(`Verification command ${command.id} is missing`); + persisted.exitCode = executed.result.code; + persisted.stdout = executed.result.stdout.slice(0, 32 * 1024); + persisted.stderr = executed.result.stderr.slice(0, 32 * 1024); + persisted.status = executed.result.code === 0 ? "succeeded" : "failed"; + }); + } catch (error) { + if (error instanceof WorkspaceSafetyError) safetyFailure = error; + run = await this.transition(run, (next) => { + const persisted = next.verificationEvidence?.commands.find( + (candidate) => candidate.id === command.id, + ); + if (!persisted) throw new Error(`Verification command ${command.id} is missing`); + persisted.status = "failed"; + persisted.error = error instanceof Error ? error.message : String(error); + }); + } + if (safetyFailure) { + return this.openLifecycleRecovery(run, safetyFailure.message); + } + const result = run.verificationEvidence?.commands.find( + (candidate) => candidate.id === command.id, + ); + if (result?.status !== "succeeded") return run; + } + return run; + } + + private reviewSelection(run: WorkflowRun) { + const evaluations = eligibleProfiles( + run.pinnedProfiles, + { ...run.facts, stage: "review" }, + "review", + ); + const candidate = evaluations.find( + (evaluation) => + evaluation.eligible && + !run.mutatorProfileNames.includes(evaluation.profile.name), + ); + if (!candidate) { + throw new Error("No independent review Agent Profile remains eligible"); + } + return { + profile: candidate.profile, + selection: { + eligibleProfiles: evaluations.map((evaluation) => ({ + profileName: evaluation.profile.name, + profileDigest: evaluation.profile.digest, + eligible: + evaluation.eligible && + !run.mutatorProfileNames.includes(evaluation.profile.name), + reasons: [ + ...evaluation.reasons, + ...(run.mutatorProfileNames.includes(evaluation.profile.name) + ? ["profile mutated the reviewed diff"] + : []), + ], + specificity: evaluation.specificity, + })), + selectedProfileName: candidate.profile.name, + rationale: "Selected an eligible reviewer outside the cumulative mutator set.", + }, + }; + } + + private async queueReviewAssignment(run: WorkflowRun) { + const selected = this.reviewSelection(run); + return this.transition(run, (next) => { + next.assignments.push({ + id: this.createId(), + stageId: stage(next, "review").id, + role: "review", + profileName: selected.profile.name, + profileDigest: selected.profile.digest, + selection: selected.selection, + status: "queued", + mutates: false, + ownedPaths: [], + }); + const review = stage(next, "review"); + review.status = "pending"; + review.startedAt = undefined; + review.finishedAt = undefined; + }); + } + + private async remediate(run: WorkflowRun, reviewerProfileName?: string) { + const persistedRemediation = this.queuedAssignment(run, "remediate") + ? undefined + : stage(run, "remediate").status !== "completed" + ? this.succeededAssignment(run, "remediate") + : undefined; + if (persistedRemediation) { + run = await this.markStageCompleted( + run, + "remediate", + implementationOutput(persistedRemediation.result), + ); + return this.finishRemediation(run); + } + if (!reviewerProfileName) { + run = await this.transition(run, (next) => { + next.verificationCycles++; + }); + if (run.verificationCycles >= run.effectiveConfig.maxReviewCycles) { + return this.openLifecycleRecovery( + run, + `Verification did not pass after ${run.verificationCycles} remediation cycles`, + ); + } + } else if (run.reviewCycles >= run.effectiveConfig.maxReviewCycles) { + return this.openLifecycleRecovery( + run, + `Review did not approve after ${run.reviewCycles} cycles`, + ); + } + const evaluations = eligibleProfiles( + run.pinnedProfiles, + { ...run.facts, stage: "implement" }, + "implement", + ); + const selected = evaluations.find( + (evaluation) => + evaluation.eligible && evaluation.profile.name !== reviewerProfileName, + ); + if (!selected) { + return this.failRun( + run, + "remediation-profile-unavailable", + new Error("No eligible remediation profile remains"), + ); + } + const ownedPaths = [ + ...new Set( + run.assignments + .filter((assignment) => assignment.mutates) + .flatMap((assignment) => assignment.ownedPaths), + ), + ]; + run = await this.transition(run, (next) => { + const remediate = stage(next, "remediate"); + remediate.status = "pending"; + remediate.startedAt = undefined; + remediate.finishedAt = undefined; + next.assignments.push({ + id: this.createId(), + stageId: remediate.id, + role: "remediate", + profileName: selected.profile.name, + profileDigest: selected.profile.digest, + selection: { + eligibleProfiles: evaluations.map((evaluation) => ({ + profileName: evaluation.profile.name, + profileDigest: evaluation.profile.digest, + eligible: + evaluation.eligible && + evaluation.profile.name !== reviewerProfileName, + reasons: [ + ...evaluation.reasons, + ...(evaluation.profile.name === reviewerProfileName + ? ["reviewer cannot remediate its own findings"] + : []), + ], + specificity: evaluation.specificity, + })), + selectedProfileName: selected.profile.name, + rationale: "Selected an eligible implementer distinct from the reviewer.", + }, + status: "queued", + mutates: true, + ownedPaths, + }); + }); + const assignment = this.queuedAssignment(run, "remediate"); + if (!assignment) throw new Error("Remediation Assignment is missing"); + const execution = await this.runPlannedAssignment(run, assignment); + run = execution.run; + if (!execution.output || run.status !== "running") return run; + const output = implementationOutput(execution.output); + run = await this.markStageCompleted(run, "remediate", output); + return this.finishRemediation(run); + } + + private async finishRemediation(run: WorkflowRun) { + run = await this.transition(run, (next) => { + if (!next.verificationEvidence) throw new Error("Verification evidence is missing"); + for (const command of next.verificationEvidence.commands) { + if (command.status === "superseded") continue; + command.status = "pending"; + command.exitCode = undefined; + command.stdout = undefined; + command.stderr = undefined; + command.error = undefined; + } + next.verificationEvidence.focusedBehavioralTests = false; + next.verificationEvidence.verifierSucceeded = false; + next.reviewVerdict = undefined; + const verifier = selectionEvidence( + next.pinnedProfiles, + { ...next.facts, stage: "verify" }, + "verify", + ); + next.assignments.push({ + id: this.createId(), + stageId: stage(next, "verify").id, + role: "verify", + profileName: verifier.profile.name, + profileDigest: verifier.profile.digest, + selection: verifier.selection, + status: "queued", + mutates: false, + ownedPaths: [], + }); + const verify = stage(next, "verify"); + verify.status = "pending"; + verify.startedAt = undefined; + verify.finishedAt = undefined; + }); + if (!this.queuedAssignment(run, "review")) { + run = await this.queueReviewAssignment(run); + } + return this.runImplementationPipeline(run); + } + + private async runPrototypeStage(run: WorkflowRun): Promise { + if (run.uiImpact !== "non-trivial" || run.implementationContext) return run; + while (true) { + const assignment = run.assignments.find( + (candidate) => + candidate.role === "prototype" && + !run.prototypeCandidates.some( + (prototype) => prototype.assignmentId === candidate.id, + ) && + (candidate.status === "queued" || candidate.status === "succeeded"), + ); + if (!assignment) break; + let rawOutput = assignment.result; + if (assignment.status === "queued") { + const execution = await this.runPlannedAssignment(run, assignment); + run = execution.run; + if (!execution.output || run.status !== "running") return run; + rawOutput = execution.output; + } + const output = prototypeOutput(rawOutput); + if (!this.options.prototypeValidator) { + return this.openLifecycleRecovery( + run, + "Prototype validation is unavailable", + [assignment.id], + ); + } + let hostValidation: { artifactFiles: string[]; visualDigest: string }; + try { + hostValidation = await this.options.prototypeValidator.validateCandidate({ + worktreeRoot: run.workspace.root ?? run.workspace.sourceRoot, + assignment, + output, + }); + } catch (error) { + return this.openLifecycleRecovery( + run, + `Prototype ${assignment.id} failed host validation: ${error instanceof Error ? error.message : String(error)}`, + [assignment.id], + ); + } + const candidatePath = output.path.replaceAll("\\", "/").replace(/^\.\//, ""); + if ( + path.isAbsolute(output.path) || + candidatePath.split("/").includes("..") || + !assignment.ownedPaths.some((ownedPath) => { + const root = ownedPath.replaceAll("\\", "/").replace(/\/$/, ""); + return candidatePath === root || candidatePath.startsWith(`${root}/`); + }) + ) { + return this.openLifecycleRecovery( + run, + `Prototype ${assignment.id} reported a path outside its ownership`, + [assignment.id], + ); + } + const revision = run.revision + 1; + const candidate = { + id: assignment.id, + revision, + assignmentId: assignment.id, + profileName: assignment.profileName, + status: "succeeded" as const, + path: candidatePath, + route: output.route, + structuralRationale: output.structuralRationale, + responsiveChecks: output.responsiveChecks, + browserEvidence: output.browserEvidence, + launchInstructions: output.launchInstructions, + visualDigest: hostValidation.visualDigest, + artifactPath: "", + retainedArtifacts: [] as string[], + }; + const artifact = await this.options.store.savePrototypeArtifact( + run.runId, + candidate.id, + candidate, + run.workspace.root ?? run.workspace.sourceRoot, + hostValidation.artifactFiles, + ); + candidate.artifactPath = artifact.path; + candidate.retainedArtifacts = artifact.retainedFiles; + run = await this.transition(run, (next) => { + next.prototypeCandidates.push(structuredClone(candidate)); + }); + } + + const candidates = run.prototypeCandidates.slice(-3); + if (candidates.length !== 3) { + return this.openLifecycleRecovery( + run, + "Non-trivial UI work requires three successful Prototype Candidates", + ); + } + for (const key of [ + "path", + "route", + "structuralRationale", + "visualDigest", + ] as const) { + if (new Set(candidates.map((candidate) => candidate[key])).size !== 3) { + return this.openLifecycleRecovery( + run, + `Prototype Candidates must have distinct ${key} values`, + candidates.map((candidate) => candidate.assignmentId), + ); + } + } + return this.transition(run, (next) => { + const prototype = stage(next, "prototype"); + prototype.status = "waiting"; + prototype.output = structuredClone(candidates); + next.status = "waiting"; + const gate: WorkflowGate = { + id: this.createId(), + kind: "prototype-acceptance", + status: "open", + revision: next.revision + 1, + openedAt: this.now(), + payload: { candidates: structuredClone(candidates) }, + }; + openGate(next, gate); + }); + } + + private async runPrototypeCleanup(run: WorkflowRun) { + if (run.prototypeCleanupCompleted) return run; + const accepted = run.decisions.some( + (decision) => decision.value.kind === "accept-prototype", + ); + if (!accepted) return run; + const cleanup = [...run.assignments] + .reverse() + .find( + (assignment) => + assignment.role === "remediate" && + assignment.selection.rationale.startsWith("Remove prototype-only"), + ); + if (!cleanup) { + return this.openLifecycleRecovery(run, "Prototype cleanup Assignment is missing"); + } + let rawOutput = cleanup.result; + if (cleanup.status === "queued") { + const execution = await this.runPlannedAssignment(run, cleanup); + run = execution.run; + if (!execution.output || run.status !== "running") return run; + rawOutput = execution.output; + } else if (cleanup.status !== "succeeded") { + return this.openLifecycleRecovery( + run, + "Prototype cleanup did not complete safely", + [cleanup.id], + ); + } + const output = implementationOutput(rawOutput); + if (!this.options.prototypeValidator) { + return this.openLifecycleRecovery(run, "Prototype cleanup validation is unavailable"); + } + try { + await this.options.prototypeValidator.validateCleanup({ + worktreeRoot: run.workspace.root ?? run.workspace.sourceRoot, + candidates: run.prototypeCandidates, + }); + } catch (error) { + return this.openLifecycleRecovery( + run, + `Prototype cleanup failed host validation: ${error instanceof Error ? error.message : String(error)}`, + [cleanup.id], + ); + } + run = await this.markStageCompleted(run, "remediate", output); + return this.transition(run, (next) => { + next.prototypeCleanupCompleted = true; + }); + } + + private async runImplementationPipeline(run: WorkflowRun): Promise { + if (run.implementationContext?.status !== "ready") return run; + const implementStage = stage(run, "implement"); + if (implementStage.status !== "completed") { + const assignment = this.queuedAssignment(run, "implement"); + const persisted = assignment + ? undefined + : this.succeededAssignment(run, "implement"); + if (!assignment && !persisted) { + return this.openLifecycleRecovery(run, "Implementation Assignment is missing"); + } + if (persisted) { + run = await this.markStageCompleted( + run, + "implement", + implementationOutput(persisted.result), + ); + } else if (assignment) { + const execution = await this.runPlannedAssignment(run, assignment); + run = execution.run; + if (!execution.output || run.status !== "running") return run; + const output = implementationOutput(execution.output); + run = await this.markStageCompleted(run, "implement", output); + } + } + + run = await this.runPrototypeCleanup(run); + if (run.status !== "running") return run; + + run = await this.runVerificationCommands(run); + if (run.status !== "running") return run; + if ( + run.verificationEvidence?.commands.some( + (command) => + command.status !== "succeeded" && command.status !== "superseded", + ) + ) { + return this.remediate(run); + } + + const verifyAssignment = this.queuedAssignment(run, "verify"); + const persistedVerification = verifyAssignment + ? undefined + : stage(run, "verify").status !== "completed" + ? this.succeededAssignment(run, "verify") + : undefined; + if (verifyAssignment || persistedVerification) { + let output: VerificationOutput; + if (persistedVerification) { + output = verificationOutput(persistedVerification.result); + } else { + const execution = await this.runPlannedAssignment(run, verifyAssignment!); + run = execution.run; + if (!execution.output || run.status !== "running") return run; + output = verificationOutput(execution.output); + } + const focusedCommandPassed = + run.verificationEvidence?.commands.some( + (command) => command.kind === "test" && command.status === "succeeded", + ) ?? false; + run = await this.transition(run, (next) => { + if (!next.verificationEvidence) throw new Error("Verification evidence is missing"); + next.verificationEvidence.focusedBehavioralTests = + output.focusedBehavioralTests && focusedCommandPassed; + next.verificationEvidence.verifierSucceeded = output.succeeded; + const verify = stage(next, "verify"); + verify.output = output; + verify.status = output.succeeded ? "completed" : "failed"; + verify.finishedAt = this.now(); + }); + if ( + !output.succeeded || + !output.focusedBehavioralTests || + !focusedCommandPassed + ) { + return this.remediate(run); + } + } + if ( + !run.verificationEvidence?.verifierSucceeded || + !run.verificationEvidence.focusedBehavioralTests + ) { + return this.openLifecycleRecovery( + run, + "Completion requires successful verification and focused behavioral tests", + ); + } + + run = await this.runWorkflowScript(run, "before-review"); + if (run.status === "failed") return run; + + let reviewAssignment = this.queuedAssignment(run, "review"); + let persistedReview = reviewAssignment + ? undefined + : stage(run, "review").status !== "completed" + ? this.succeededAssignment(run, "review") + : undefined; + if (!reviewAssignment && !persistedReview) { + run = await this.queueReviewAssignment(run); + reviewAssignment = this.queuedAssignment(run, "review"); + persistedReview = undefined; + } + if (!reviewAssignment && !persistedReview) { + throw new Error("Review Assignment is missing"); + } + const reviewer = reviewAssignment ?? persistedReview!; + if (run.mutatorProfileNames.includes(reviewer.profileName)) { + return this.failRun( + run, + "independent-reviewer-unavailable", + new Error("Review profile mutated the reviewed diff"), + ); + } + let verdict: ReviewOutput; + if (persistedReview) { + verdict = reviewOutput(persistedReview.result); + } else { + const execution = await this.runAssignment( + run, + "review", + this.profileForAssignment(run, reviewer), + reviewer, + ); + run = execution.run; + if (!execution.output || run.status !== "running") return run; + verdict = reviewOutput(execution.output); + } + run = await this.transition(run, (next) => { + next.reviewVerdict = verdict; + next.reviewCycles++; + const review = stage(next, "review"); + review.output = verdict; + review.finishedAt = this.now(); + review.status = + verdict.specification.verdict === "approved" && + verdict.standards.verdict === "approved" + ? "completed" + : "failed"; + }); + if ( + verdict.specification.verdict !== "approved" || + verdict.standards.verdict !== "approved" + ) { + return this.remediate(run, reviewer.profileName); + } + return this.transition(run, (next) => { + stage(next, "complete").status = "completed"; + stage(next, "complete").startedAt ??= this.now(); + stage(next, "complete").finishedAt = this.now(); + next.status = "completed"; + next.error = undefined; + }); + } + + private assertContextCommand( + run: WorkflowRun, + command: ContextCommandBase, + ) { + if (command.expectedRevision !== run.revision) { + throw new Error( + `Stale workflow revision ${command.expectedRevision}; current revision is ${run.revision}`, + ); + } + if (run.status !== "waiting") { + throw new Error("Implementation Context transition requires a waiting Workflow Run"); + } + const context = run.implementationContext; + const gate = activeGate(run); + if ( + !context || + !gate || + gate.kind !== "implementation-context" || + gate.id !== command.gateId || + context.gateId !== command.gateId || + context.preparationId !== command.preparationId || + context.planRevision !== command.planRevision || + context.runRevision !== command.contextRevision + ) { + throw new Error("Implementation Context command is stale or mismatched"); + } + if (!run.planArtifact || run.planArtifact.revision !== command.planRevision) { + throw new Error("Implementation Context plan revision is stale"); + } + if ( + run.assignments.some( + (assignment) => + assignment.status === "running" || + (assignment.status === "queued" && Boolean(assignment.executionToken)), + ) + ) { + throw new Error("Implementation Context cannot change while an Assignment is active"); + } + return { context, gate }; + } + + private async continueContext( + run: WorkflowRun, + command: ContinueContextCommand, + ) { + const { context } = this.assertContextCommand(run, command); + if (context.status === "handoff-pending" && context.strategy === "fresh-session") { + if (!command.sourceSessionFile) { + throw new Error("fresh-session continuation requires sourceSessionFile"); + } + run = await this.transition(run, (next) => { + const state = next.implementationContext; + if (!state) throw new Error("Implementation Context state is missing"); + state.sourceSessionFile = command.sourceSessionFile; + state.intentCreatedAt = this.now(); + state.intentExpiresAt = this.now() + 5 * 60_000; + state.runRevision = next.revision + 1; + }); + return view(run); + } + if ( + context.status === "normal-compact-pending" && + context.strategy === "smart-compact" && + context.outcome === "unavailable" + ) { + run = await this.transition(run, (next) => { + const state = next.implementationContext; + if (!state) throw new Error("Implementation Context state is missing"); + state.status = "normal-compact-running"; + state.method = "normal-compact"; + state.error = undefined; + state.runRevision = next.revision + 1; + }); + return view(run); + } + throw new Error("Workflow Run has no continuable context intent"); + } + + private async claimContextHandoff( + run: WorkflowRun, + command: ClaimContextHandoffCommand, + ) { + const { context } = this.assertContextCommand(run, command); + if ( + context.status !== "handoff-pending" || + context.strategy !== "fresh-session" || + context.sourceSessionFile !== command.sourceSessionFile || + context.sourceParentSessionId !== this.options.parentSessionId + ) { + throw new Error("Fresh-session handoff claim does not match the pending intent"); + } + run = await this.transition(run, (next) => { + const state = next.implementationContext; + if (!state) throw new Error("Implementation Context state is missing"); + state.targetSessionFile = command.targetSessionFile; + state.claimedAt = this.now(); + state.status = "claimed-target-awaiting-withSession"; + state.runRevision = next.revision + 1; + }); + return view(run); + } + + private async consumeContextHandoff( + run: WorkflowRun, + command: ConsumeContextHandoffCommand, + ) { + const { context, gate } = this.assertContextCommand(run, command); + if ( + context.status !== "claimed-target-awaiting-withSession" || + context.strategy !== "fresh-session" || + context.targetSessionFile !== command.targetSessionFile || + context.sourceSessionFile !== command.previousSessionFile || + context.activeParentSessionId === this.options.parentSessionId || + (context.intentExpiresAt !== undefined && context.intentExpiresAt < this.now()) + ) { + throw new Error("Replacement session does not match the claimed handoff"); + } + run = await this.transition(run, (next) => { + const state = next.implementationContext; + if (!state) throw new Error("Implementation Context state is missing"); + state.activeParentSessionId = this.options.parentSessionId; + state.status = "ready"; + state.method = "fresh-session"; + state.outcome = "succeeded"; + state.error = undefined; + state.runRevision = next.revision + 1; + next.parentSessionIds = [ + ...new Set([...next.parentSessionIds, this.options.parentSessionId]), + ]; + if (!next.lease) throw new Error("Workflow Run lease is missing"); + next.lease.activeParentSessionId = this.options.parentSessionId; + const nextGate = next.gates.find((candidate) => candidate.id === gate.id); + if (!nextGate || nextGate.status !== "open") { + throw new Error("Implementation Context Gate is not open"); + } + nextGate.status = "satisfied"; + next.status = "running"; + }); + run = await this.runImplementationPipeline(run); + return view(run); + } + + private async recoverContextIntent( + run: WorkflowRun, + command: RecoverContextIntentCommand, + ) { + if ( + command.reason !== "expired" && + run.lease && + run.lease.processId !== this.processId && + this.isProcessAlive(run.lease.processId) + ) { + throw new Error( + `Context handoff is owned by live process ${run.lease.processId}`, + ); + } + const { context } = this.assertContextCommand(run, command); + const cancelledUnclaimed = + command.reason === "cancelled" && + context.status === "handoff-pending" && + Boolean(context.sourceSessionFile); + if ( + context.status !== "claimed-target-awaiting-withSession" && + !cancelledUnclaimed + ) { + throw new Error("Workflow Run has no context intent to recover"); + } + run = await this.transition(run, (next) => { + const state = next.implementationContext; + if (!state) throw new Error("Implementation Context state is missing"); + state.status = "handoff-pending"; + state.sourceSessionFile = undefined; + state.targetSessionFile = undefined; + state.claimedAt = undefined; + state.intentCreatedAt = undefined; + state.intentExpiresAt = undefined; + state.error = + command.reason === "cancelled" + ? "Fresh-session handoff was cancelled before session replacement; retry when ready" + : `Fresh-session handoff was not consumed during ${command.reason}; resume the source session and retry`; + state.runRevision = next.revision + 1; + next.status = "waiting"; + }); + return view(run); + } + + private async completeNormalCompaction( + run: WorkflowRun, + command: CompleteNormalCompactionCommand, + ) { + const { context, gate } = this.assertContextCommand(run, command); + if ( + context.status !== "normal-compact-running" || + context.method !== "normal-compact" + ) { + throw new Error("Normal compaction callback does not match the pending intent"); + } + run = await this.transition(run, (next) => { + const state = next.implementationContext; + if (!state) throw new Error("Implementation Context state is missing"); + state.outcome = command.outcome; + state.runRevision = next.revision + 1; + if (command.outcome === "failed") { + state.status = "failed"; + state.error = command.error; + next.status = "waiting"; + return; + } + state.status = "ready"; + state.error = undefined; + const nextGate = next.gates.find((candidate) => candidate.id === gate.id); + if (!nextGate || nextGate.status !== "open") { + throw new Error("Implementation Context Gate is not open"); + } + nextGate.status = "satisfied"; + next.status = "running"; + }); + if (command.outcome !== "failed") { + run = await this.runImplementationPipeline(run); + } + return view(run); + } + + private async abort(run: WorkflowRun, command: AbortWorkflowCommand) { + const duplicate = this.commandReceipt(run, command); + if (duplicate) { + if (duplicate.status === "completed" && duplicate.result) { + return duplicate.result as WorkflowView; + } + throw new Error("Abort command is pending recovery"); + } + if (command.expectedRevision !== run.revision) { + throw new Error( + `Stale workflow revision ${command.expectedRevision}; current revision is ${run.revision}`, + ); + } + if ( + run.status === "completed" || + run.status === "failed" || + run.status === "aborted" + ) { + throw new Error(`Cannot abort a ${run.status} Workflow Run`); + } + const commandFingerprint = fingerprint(command); + const activeExecutions = [...ACTIVE_ASSIGNMENT_EXECUTIONS.values()].filter( + (execution) => execution.runId === run.runId, + ); + const localAssignmentIds = new Set( + activeExecutions.map((execution) => execution.assignmentId), + ); + const externallyOwned = run.assignments.filter( + (assignment) => + !localAssignmentIds.has(assignment.id) && + Boolean(assignment.executionToken) && + (assignment.status === "queued" || assignment.status === "running"), + ); + for (const assignment of externallyOwned) { + const lease = await this.options.store.loadAssignmentLease( + run.runId, + assignment.id, + ); + if (lease && this.isProcessAlive(lease.processId)) { + throw new Error( + `Cannot safely abort Assignment ${assignment.id} owned by live process ${lease.processId}; abort from the owning process or retry after it exits`, + ); + } + } + const activeScript = ACTIVE_SCRIPT_EXECUTIONS.get(run.runId); + for (const execution of activeExecutions) { + execution.controller.abort(new Error("Workflow Run was aborted")); + } + activeScript?.controller.abort(new Error("Workflow Run was aborted")); + try { + await waitForAssignmentStops( + activeExecutions, + this.assignmentAbortTimeoutMs, + ); + if (activeScript) { + const stopped = await Promise.race([ + activeScript.settled.then(() => true), + new Promise((resolve) => { + const timer = setTimeout( + () => resolve(false), + this.assignmentAbortTimeoutMs, + ); + timer.unref?.(); + }), + ]); + if (!stopped) { + throw new Error( + "Timed out stopping active script; Workflow Run was not aborted", + ); + } + } + } catch (error) { + for (const execution of activeExecutions) { + execution.resolveResumeAfterAbort(); + } + throw error; + } + const latest = + activeExecutions.length > 0 || activeScript + ? await this.options.store.loadRun(run.runId) + : run; + const next = structuredClone(latest); + next.revision++; + next.status = "aborted"; + next.lease = undefined; + const interruptedIds = new Set([ + ...activeExecutions.map((execution) => execution.assignmentId), + ...externallyOwned.map((assignment) => assignment.id), + ]); + for (const assignment of next.assignments) { + if (!interruptedIds.has(assignment.id)) continue; + assignment.status = "interrupted"; + assignment.error = "Workflow Run was aborted"; + } + for (const execution of activeExecutions) { + const assignment = next.assignments.find( + (candidate) => candidate.id === execution.assignmentId, + ); + if (!assignment) continue; + applyExecutionMetadata( + assignment, + execution.metadata, + execution.transcriptArtifact, + ); + } + next.deliveries.push({ + key: `terminal:${next.runId}:aborted:${next.revision}`, + state: "pending", + createdAt: this.now(), + }); + next.reportArtifact = await this.options.store.saveReport(next); + const result = view(next); + next.commandReceipts.push({ + key: command.idempotencyKey, + action: "abort", + fingerprint: commandFingerprint, + status: "completed", + acceptedRevision: next.revision, + result, + }); + try { + await this.options.store.saveRun(next, latest.revision); + await this.options.store.publishResult(next).catch(() => {}); + await Promise.resolve(this.options.afterRunPersist?.(next)).catch(() => {}); + } finally { + for (const execution of activeExecutions) { + execution.resolveResumeAfterAbort(); + } + } + return result; + } + + private async resolveRecovery(run: WorkflowRun, decision: GateDecision) { + if (decision.kind !== "recover") return run; + if (decision.resolution === "abort") { + return this.transition(run, (next) => { + next.status = "aborted"; + next.lease = undefined; + }); + } + if (decision.resolution === "retry") { + if (run.mode === "feature" && run.workspace.state !== "ready") { + if ( + run.lease && + run.lease.processId !== this.processId && + this.isProcessAlive(run.lease.processId) + ) { + throw new Error( + "Workspace creation is still owned by a live process", + ); + } + const creating = { ...run.workspace, state: "creating" as const }; + let workspace = await this.options.workspace.reconcile( + creating, + run.lease?.worktreeIdentity, + ); + if (workspace.state === "creating") { + try { + workspace = await this.options.workspace.create( + this.workspacePlanForRun(run), + ); + } catch (error) { + workspace = { + ...creating, + error: error instanceof Error ? error.message : String(error), + }; + } + } + run = await this.persistWorkspaceOutcome(run, workspace); + if (run.workspace.state === "ready") return this.runDiscovery(run); + return run; + } + const interruptedMutating = run.assignments.filter( + (assignment) => assignment.status === "interrupted" && assignment.mutates, + ); + const interruptedReadOnly = run.assignments.filter( + (assignment) => assignment.status === "interrupted" && !assignment.mutates, + ); + const interruptedCommands = + run.verificationEvidence?.commands.filter( + (command) => command.status === "running" || command.status === "interrupted", + ) ?? []; + run = await this.transition(run, (next) => { + for (const assignment of next.assignments) { + if ( + interruptedReadOnly.some((candidate) => candidate.id === assignment.id) + ) { + if ( + assignment.selection.rationale.startsWith( + "Custom read-only script Assignment", + ) + ) { + assignment.status = "interrupted"; + } else { + assignment.status = "queued"; + } + assignment.executionToken = undefined; + } + } + if ( + interruptedReadOnly.some((assignment) => + assignment.selection.rationale.startsWith( + "Custom read-only script Assignment", + ), + ) && + next.script + ) { + next.script.status = "pending"; + next.script.executed = false; + next.script.error = undefined; + } + if (next.verificationEvidence) { + for (const command of interruptedCommands) { + const persisted = next.verificationEvidence.commands.find( + (candidate) => candidate.id === command.id, + ); + if (!persisted) continue; + persisted.status = "superseded"; + next.verificationEvidence.commands.push({ + ...structuredClone(persisted), + id: this.createId(), + status: "pending", + exitCode: undefined, + stdout: undefined, + stderr: undefined, + error: undefined, + }); + } + } + next.status = "running"; + }); + if (run.implementationContext?.status === "ready") { + if (interruptedMutating.length > 0) { + run = await this.transition(run, (next) => { + const implement = stage(next, "implement"); + if (implement.status !== "completed") { + implement.status = "completed"; + implement.finishedAt = this.now(); + implement.output = { + recovery: "User requested a new remediation Assignment after interruption", + }; + } + }); + return this.remediate(run); + } + return this.runImplementationPipeline(run); + } + if ( + interruptedMutating.length > 0 && + interruptedMutating.every((assignment) => assignment.role === "prototype") && + run.uiImpact === "non-trivial" + ) { + run = await this.transition(run, (next) => { + for (const interrupted of interruptedMutating) { + const root = interrupted.ownedPaths[0]?.replace( + /[/\\](?:candidate|refinement-[^/\\]+-candidate)-\d+$/, + "", + ); + if (!root) throw new Error("Interrupted Prototype ownership is invalid"); + next.assignments.push({ + ...structuredClone(interrupted), + id: this.createId(), + status: "queued", + executionToken: undefined, + result: undefined, + error: undefined, + selection: { + ...structuredClone(interrupted.selection), + rationale: + "Explicit Recovery retry creates a fresh Prototype Assignment after interruption.", + }, + ownedPaths: [ + path.join(root, `recovery-${next.revision + 1}-${this.createId()}`), + ], + }); + } + next.status = "running"; + }); + return this.runPrototypeStage(run); + } + if (interruptedMutating.length > 0) { + return this.openLifecycleRecovery( + run, + "Interrupted mutating Assignments remain preserved and cannot be replayed before implementation context is approved", + interruptedMutating.map((assignment) => assignment.id), + ); + } + return this.resumePersistedContinuation(run); + } + return this.transition(run, (next) => { + next.status = "waiting"; + }); + } + + private workspacePlanForRun(run: WorkflowRun): WorkspacePlan { + const { workspace } = run; + if (!workspace.root || !workspace.branch || !workspace.baseHead) { + throw new Error( + "Workflow Run is missing persisted workspace creation intent", + ); + } + return { + sourceRoot: workspace.sourceRoot, + worktreeRoot: path.dirname(workspace.root), + worktreePath: workspace.root, + branch: workspace.branch, + baseHead: workspace.baseHead, + branchExists: false, + }; + } + + private async resumePendingDecision( + run: WorkflowRun, + receipt: CommandReceipt, + ) { + const decision = run.decisions.find( + (candidate) => candidate.idempotencyKey === receipt.key, + ); + if (!decision) + throw new Error(`Decision for receipt ${receipt.key} is missing`); + const gate = run.gates.find( + (candidate) => candidate.id === decision.gateId, + ); + if (!gate) throw new Error(`Gate ${decision.gateId} is missing`); + const continuation = + receipt.continuation ?? decisionContinuation(gate.kind, decision.value); + if (continuation === "design") { + const laterPlanGate = run.gates.some( + (candidate) => + candidate.kind === "plan-approval" && + candidate.status === "open" && + candidate.revision > receipt.acceptedRevision, + ); + if (!laterPlanGate && run.status !== "failed") { + run = await this.runDesign( + run, + decision.value.kind === "revise-plan" && + run.revision === receipt.acceptedRevision, + ); + } + const planGateReady = run.gates.some( + (candidate) => + candidate.kind === "plan-approval" && + candidate.status === "open" && + candidate.revision > receipt.acceptedRevision, + ); + if (!planGateReady && run.status !== "failed") return view(run); + } else if (continuation === "prototype") { + if (run.status !== "failed") { + run = await this.runPrototypeStage(run); + } + } else if (continuation === "implementation") { + if (run.status !== "failed") { + run = await this.runImplementationPipeline(run); + } + } else if (continuation === "recovery") { + run = await this.resolveRecovery(run, decision.value); + } + return this.completeReceipt(run, receipt.key); + } + + private commandReceipt( + run: WorkflowRun, + command: DecideWorkflowCommand | AbortWorkflowCommand, + ) { + const receipt = run.commandReceipts.find( + (candidate) => candidate.key === command.idempotencyKey, + ); + if (!receipt) return undefined; + if (receipt.fingerprint !== fingerprint(command)) { + throw new Error("idempotency key was already used with another command"); + } + return receipt; + } + + private async completeReceipt(run: WorkflowRun, key: string) { + const next = structuredClone(run); + next.revision++; + const receipt = next.commandReceipts.find( + (candidate) => candidate.key === key, + ); + if (!receipt) throw new Error(`Command receipt ${key} is missing`); + const result = view(next); + receipt.status = "completed"; + receipt.result = result; + await this.options.store.saveRun(next, run.revision); + return result; + } + + private async transition( + run: WorkflowRun, + update: (next: WorkflowRun) => void, + ) { + const next = structuredClone(run); + update(next); + next.revision = run.revision + 1; + if (next.lease) next.lease.heartbeatAt = this.now(); + if (isTerminalStatus(next.status) && !isTerminalStatus(run.status)) { + next.deliveries.push({ + key: `terminal:${next.runId}:${next.status}:${next.revision}`, + state: "pending", + createdAt: this.now(), + }); + next.reportArtifact = await this.options.store.saveReport(next); + } + await this.options.store.saveRun(next, run.revision); + if (isTerminalStatus(next.status)) { + await this.options.store.publishResult(next).catch(() => {}); + } + await Promise.resolve(this.options.afterRunPersist?.(next)).catch(() => {}); + return next; + } + + private async failRun(run: WorkflowRun, code: string, error: unknown) { + return this.transition(run, (next) => { + next.status = "failed"; + next.error = { + code, + message: error instanceof Error ? error.message : String(error), + }; + }); + } + + private assertActiveParent(run: WorkflowRun) { + if ( + run.lease && + run.lease.activeParentSessionId !== this.options.parentSessionId + ) { + throw new Error( + "Historical parent sessions may inspect but cannot mutate this run", + ); + } + } + + private async resumePersistedContinuation(run: WorkflowRun) { + if ( + run.script && + !run.script.executed && + (run.script.status === "pending" || run.script.status === "running") + ) { + const insertionReady = + run.mode === "analysis" || + (run.script.insertionPoint === "before-design" && + stage(run, "discover").status === "completed") || + (run.script.insertionPoint === "after-design" && + run.gates.some((gate) => gate.kind === "plan-approval")) || + (run.script.insertionPoint === "before-review" && + stage(run, "verify").status === "completed"); + if (insertionReady) { + return this.runWorkflowScript(run, run.script.insertionPoint); + } + } + const pendingDecision = run.commandReceipts.find( + (receipt) => receipt.action === "decide" && receipt.status === "pending", + ); + if (pendingDecision) { + await this.resumePendingDecision(run, pendingDecision); + return this.options.store.loadRun(run.runId); + } + const discover = stage(run, "discover"); + const design = stage(run, "design"); + if (discover.status === "running") return this.runDiscovery(run); + if (discover.status === "completed" && design.status === "running") { + return this.runDesign(run); + } + if ( + run.status === "running" && + run.uiImpact === "non-trivial" && + !run.implementationContext + ) { + return this.runPrototypeStage(run); + } + if ( + run.status === "running" && + run.implementationContext?.status === "ready" + ) { + return this.runImplementationPipeline(run); + } + return run; + } + + private async hasActiveAssignmentLease( + runId: string, + assignments: WorkflowAssignment[], + ) { + for (const assignment of assignments) { + if (!assignment.executionToken) continue; + const lease = await this.options.store.loadAssignmentLease( + runId, + assignment.id, + ); + if (!lease || lease.token !== assignment.executionToken) continue; + const ownerIsRunning = + lease.processId === this.processId + ? ACTIVE_ASSIGNMENT_TOKENS.has(lease.token) + : this.isProcessAlive(lease.processId); + const heartbeatIsFresh = + this.now() - lease.heartbeatAt <= this.leaseStaleMs; + if (ownerIsRunning && (heartbeatIsFresh || assignment.mutates)) + return true; + } + return false; + } + + private leaseIsStale(run: WorkflowRun) { + if ( + run.lease?.processId === this.processId && + run.lease.activeParentSessionId === this.options.parentSessionId + ) { + return false; + } + return ( + !run.lease || + this.now() - run.lease.heartbeatAt > this.leaseStaleMs || + !this.isProcessAlive(run.lease.processId) + ); + } + + private validateStart(command: StartWorkflowCommand) { + if (command.request.trim().length === 0) + throw new Error("request is required"); + if ( + !FEATURE_NAME.test(command.featureName) || + command.featureName.length > MAX_FEATURE_NAME_LENGTH + ) { + throw new Error( + `featureName must be lowercase kebab case and at most ${MAX_FEATURE_NAME_LENGTH} characters`, + ); + } + if (command.idempotencyKey.trim().length === 0) { + throw new Error("idempotencyKey is required"); + } + if ( + command.concurrency !== undefined && + command.concurrency > this.options.effectiveConfig.concurrency.ceiling + ) { + throw new Error( + `concurrency cannot exceed ceiling ${this.options.effectiveConfig.concurrency.ceiling}`, + ); + } + } + + private preflightEligibility(initialFacts: WorkflowFacts) { + const roles: ProfileRole[] = [ + "discover", + "design", + "implement", + "verify", + "review", + ]; + const evidence: StageEligibilityEvidence[] = roles.map((role) => { + const candidates = eligibleProfiles( + this.options.profiles, + { ...initialFacts, stage: role }, + role, + ).map((evaluation) => ({ + profileName: evaluation.profile.name, + profileDigest: evaluation.profile.digest, + eligible: evaluation.eligible, + reasons: [...evaluation.reasons], + specificity: evaluation.specificity, + })); + if (!candidates.some((candidate) => candidate.eligible)) { + throw new Error(`No eligible ${role} Agent Profile is available`); + } + return { role, candidates }; + }); + const eligibleNames = (role: ProfileRole) => + evidence + .find((entry) => entry.role === role) + ?.candidates.filter((candidate) => candidate.eligible) + .map((candidate) => candidate.profileName) ?? []; + const implementers = eligibleNames("implement"); + const reviewers = eligibleNames("review"); + if ( + !reviewers.some((reviewer) => + implementers.some((implementer) => implementer !== reviewer), + ) + ) { + throw new Error( + "No independently eligible review Agent Profile is available", + ); + } + return evidence; + } +} diff --git a/extensions/pi-workflow/execution.test.ts b/extensions/pi-workflow/execution.test.ts new file mode 100644 index 0000000..c39dab0 --- /dev/null +++ b/extensions/pi-workflow/execution.test.ts @@ -0,0 +1,862 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import type { + DefaultResourceLoader, + ExtensionContext, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { emptyUsage } from "../_workflows/model.ts"; +import type { + RunAgentOptions, + ThinkingLevel, + WorkflowModel, +} from "../_workflows/runner.ts"; +import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts"; +import { WorkflowEngine, type WorkspaceManager } from "./engine.ts"; +import { + PiAssignmentExecutor, + ProcessAssignmentScheduler, + resolveWorkflowModel, +} from "./execution.ts"; +import { loadAgentProfiles } from "./profiles.ts"; +import { WorkflowStore } from "./store.ts"; + +const execFileAsync = promisify(execFile); + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-execution-")); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function model(provider: string, id: string) { + return { + provider, + id, + contextWindow: 128_000, + } as unknown as WorkflowModel; +} + +function registry(models: WorkflowModel[]) { + return { + find(provider: string, id: string) { + return models.find((candidate) => candidate.provider === provider && candidate.id === id); + }, + getAll() { + return models; + }, + } as unknown as ExtensionContext["modelRegistry"]; +} + +function deferred() { + let resolve: (() => void) | undefined; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve: () => resolve?.() }; +} + +test("process scheduler enforces aggregate and per-run capacity with queued cancellation", async () => { + const scheduler = new ProcessAssignmentScheduler(); + scheduler.lowerCeiling(2); + const waits = [deferred(), deferred(), deferred(), deferred()]; + const controllers = waits.map(() => new AbortController()); + let active = 0; + let maximum = 0; + const tasks = waits.map((wait, index) => + scheduler.schedule({ + runId: index < 3 ? "run-a" : "run-b", + runLimit: 2, + signal: controllers[index].signal, + task: async () => { + active++; + maximum = Math.max(maximum, active); + try { + await wait.promise; + return index; + } finally { + active--; + } + }, + }), + ); + const cancelled = tasks[2].catch((error: unknown) => error); + assert.deepEqual(scheduler.snapshot(), { + ceiling: 2, + inUse: 2, + running: 0, + queued: 2, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(scheduler.snapshot(), { + ceiling: 2, + inUse: 2, + running: 2, + queued: 2, + }); + controllers[2].abort(new Error("cancel queued")); + assert.match(String(await cancelled), /cancel queued/); + waits[0].resolve(); + waits[1].resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + waits[3].resolve(); + assert.deepEqual(await Promise.all([tasks[0], tasks[1], tasks[3]]), [0, 1, 3]); + assert.equal(maximum, 2); + assert.deepEqual(scheduler.snapshot(), { + ceiling: 2, + inUse: 0, + running: 0, + queued: 0, + }); +}); + +test("exclusive mutation reservations serialize a run without blocking other runs", async () => { + const scheduler = new ProcessAssignmentScheduler(); + scheduler.lowerCeiling(3); + const waits = [deferred(), deferred(), deferred(), deferred()]; + const started: number[] = []; + const schedule = (index: number, runId: string, exclusive = false) => + scheduler.schedule({ + runId, + runLimit: 3, + exclusive, + signal: new AbortController().signal, + async task() { + started.push(index); + await waits[index].promise; + }, + }); + const firstRead = schedule(0, "run-a"); + const mutation = schedule(1, "run-a", true); + const laterRead = schedule(2, "run-a"); + const otherRun = schedule(3, "run-b"); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(started, [0, 3]); + waits[0].resolve(); + await firstRead; + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(started, [0, 3, 1]); + waits[1].resolve(); + await mutation; + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(started, [0, 3, 1, 2]); + waits[2].resolve(); + waits[3].resolve(); + await Promise.all([laterRead, otherRun]); +}); + +test("running cancellation releases a scheduler reservation exactly once", async () => { + const scheduler = new ProcessAssignmentScheduler(); + const controller = new AbortController(); + let reserved = false; + let started = false; + const task = scheduler.schedule({ + runId: "run-a", + runLimit: 1, + signal: controller.signal, + async onReserved() { + reserved = true; + }, + async task(signal) { + started = true; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(new Error("cancel running")); + await assert.rejects(task, /cancel running/); + assert.equal(reserved, true); + assert.equal(started, true); + assert.deepEqual(scheduler.snapshot(), { + ceiling: 32, + inUse: 0, + running: 0, + queued: 0, + }); +}); + +test("model resolution is exact, inherited-provider aware, and ambiguity safe", () => { + const inherited = model("provider-a", "parent"); + const sharedA = model("provider-a", "shared"); + const sharedB = model("provider-b", "shared"); + const models = registry([inherited, sharedA, sharedB]); + assert.equal(resolveWorkflowModel(models, {}, inherited), inherited); + assert.throws( + () => resolveWorkflowModel(registry([]), {}, inherited), + /Unknown inherited model/, + ); + assert.equal(resolveWorkflowModel(models, { model: "shared" }, inherited), sharedA); + assert.equal( + resolveWorkflowModel(models, { provider: "provider-b", model: "shared" }, inherited), + sharedB, + ); + assert.throws( + () => resolveWorkflowModel(models, { model: "shared" }, undefined), + /multiple providers/, + ); + assert.throws( + () => resolveWorkflowModel(models, { provider: "provider-a" }, inherited), + /requires a model/, + ); +}); + +test("the immutable process ceiling caps aggregate reservations at 32", async () => { + const scheduler = new ProcessAssignmentScheduler(); + const waits = Array.from({ length: 40 }, deferred); + let maximum = 0; + let active = 0; + const tasks = waits.map((wait, index) => + scheduler.schedule({ + runId: `run-${index % 4}`, + runLimit: 32, + signal: new AbortController().signal, + task: async () => { + active++; + maximum = Math.max(maximum, active); + try { + await wait.promise; + } finally { + active--; + } + }, + }), + ); + assert.equal(scheduler.snapshot().inUse, 32); + assert.equal(scheduler.snapshot().queued, 8); + await new Promise((resolve) => setTimeout(resolve, 0)); + waits.forEach((wait) => wait.resolve()); + await Promise.all(tasks); + assert.equal(maximum, 32); + assert.equal(scheduler.snapshot().inUse, 0); +}); + +test("production executor resolves trust and creates fresh structured child runs", async () => { + await withTempDir(async (directory) => { + const profiles = ( + await loadAgentProfiles({ cwd: directory, projectTrusted: false }) + ).profiles; + const profile = profiles.find((candidate) => candidate.roles.includes("discover")); + assert.ok(profile); + const inherited = model("provider-a", "parent"); + const models = registry([inherited]); + const scheduler = new ProcessAssignmentScheduler(); + let resources = 0; + let sessions = 0; + const trustedChildren: string[] = []; + const observedTools: string[][] = []; + const observedCustomTools: string[][] = []; + const fakeResources = { + loader: {} as DefaultResourceLoader, + settingsManager: {} as SettingsManager, + }; + const fakeRunAgent = async (options: RunAgentOptions) => { + sessions++; + observedTools.push(options.tools ?? []); + observedCustomTools.push( + options.customTools?.map((tool) => tool.name) ?? [], + ); + options.onSessionCreated?.({ + childSessionId: `child-${sessions}`, + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + }); + return { + ok: true, + output: "", + structured: { + questions: [], + candidatePaths: ["src/index.ts"], + languages: ["typescript"], + tags: ["backend"], + riskTags: [], + }, + aborted: false, + usage: emptyUsage(), + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + childSessionId: `child-${sessions}`, + contextWindow: inherited.contextWindow, + transcript: [{ role: "assistant" as const, text: "done" }], + }; + }; + const executor = new PiAssignmentExecutor({ + modelRegistry: models, + parentCwd: directory, + parentProjectTrusted: true, + parentModel: inherited, + parentThinkingLevel: "medium" as ThinkingLevel, + concurrencyCeiling: 4, + runConcurrency: 2, + scheduler, + runAgent: fakeRunAgent, + async createResources() { + resources++; + return fakeResources; + }, + resolveProjectTrust(options) { + trustedChildren.push(options.childCwd); + return false; + }, + }); + const makeRequest = (runId: string) => ({ + runId, + role: "discover" as const, + request: "Inspect the repository", + profile, + facts: { + stage: "discover" as const, + taskKind: "feature" as const, + requestTerms: ["inspect"], + candidatePaths: [], + languages: [], + tags: [], + riskTags: [], + }, + decisions: [], + cwd: path.join(directory, "worktree"), + signal: new AbortController().signal, + }); + const first = await executor.run(makeRequest("run-a")); + const second = await executor.run(makeRequest("run-b")); + await executor.run({ + ...makeRequest("run-c"), + allowedReadTools: ["grep"], + }); + assert.equal(first.kind, "pi-workflow-assignment-result"); + assert.equal(first.metadata.childSessionId, "child-1"); + assert.equal(second.metadata.childSessionId, "child-2"); + assert.equal(resources, 3); + assert.equal(sessions, 3); + assert.deepEqual(trustedChildren, [ + path.join(directory, "worktree"), + path.join(directory, "worktree"), + path.join(directory, "worktree"), + ]); + assert.ok(observedTools.every((tools) => tools.includes("structured_output"))); + assert.equal(observedTools[2]?.includes("read"), false); + assert.equal(observedTools[2]?.includes("grep"), true); + assert.deepEqual(observedCustomTools[2], ["grep"]); + + const failedExecutor = new PiAssignmentExecutor({ + modelRegistry: models, + parentCwd: directory, + parentProjectTrusted: true, + parentModel: inherited, + parentThinkingLevel: "medium" as ThinkingLevel, + concurrencyCeiling: 4, + runConcurrency: 2, + scheduler: new ProcessAssignmentScheduler(), + async createResources() { + return fakeResources; + }, + async runAgent(options) { + options.onSessionCreated?.({ + childSessionId: "child-failed", + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + }); + return { + ok: false, + output: "partial", + error: "provider failed", + aborted: false, + usage: emptyUsage(), + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + childSessionId: "child-failed", + contextWindow: inherited.contextWindow, + transcript: [{ role: "assistant" as const, text: "partial" }], + }; + }, + }); + await assert.rejects( + failedExecutor.run(makeRequest("run-failed")), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "provider failed"); + assert.equal( + (error as Error & { assignmentMetadata?: { childSessionId?: string } }) + .assignmentMetadata?.childSessionId, + "child-failed", + ); + return true; + }, + ); + }); +}); + +test("mutating children receive guarded file tools without process execution", async () => { + await withTempDir(async (directory) => { + await execFileAsync("git", ["init", "-q", directory]); + await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]); + await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]); + await mkdir(path.join(directory, "src"), { recursive: true }); + await writeFile(path.join(directory, "src", "existing.ts"), "export {};\n", "utf8"); + await execFileAsync("git", ["-C", directory, "add", "."]); + await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]); + const profiles = ( + await loadAgentProfiles({ cwd: directory, projectTrusted: false }) + ).profiles; + const profile = profiles.find((candidate) => candidate.roles.includes("implement")); + assert.ok(profile); + const inherited = model("provider-a", "parent"); + const executor = new PiAssignmentExecutor({ + modelRegistry: registry([inherited]), + parentCwd: directory, + parentProjectTrusted: false, + parentModel: inherited, + parentThinkingLevel: "medium" as ThinkingLevel, + concurrencyCeiling: 2, + runConcurrency: 1, + scheduler: new ProcessAssignmentScheduler(), + async createResources() { + return { + loader: {} as DefaultResourceLoader, + settingsManager: {} as SettingsManager, + }; + }, + async runAgent(options) { + assert.deepEqual(options.tools, [ + "read", + "grep", + "find", + "ls", + "edit", + "write", + "structured_output", + ]); + assert.equal(options.tools?.includes("bash"), false); + const writeTool = options.customTools?.find((tool) => tool.name === "write"); + assert.ok(writeTool); + await writeTool.execute( + "write-1", + { path: "src/generated.ts", content: "export const generated = true;\n" }, + options.signal ?? new AbortController().signal, + undefined, + {} as ExtensionContext, + ); + return { + ok: true, + output: "", + structured: { + plan: "# Plan", + uiImpact: "none", + uiImpactEvidence: ["No user-facing change"], + affectedUiSurfaces: [], + assignments: [], + }, + aborted: false, + usage: emptyUsage(), + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + childSessionId: "mutating-child", + contextWindow: inherited.contextWindow, + transcript: [], + }; + }, + }); + const result = await executor.run({ + runId: "run-mutation", + role: "design", + request: "Generate an owned file", + profile, + facts: { + stage: "design", + taskKind: "feature", + requestTerms: ["generate"], + candidatePaths: ["src/generated.ts"], + languages: ["typescript"], + tags: [], + riskTags: [], + }, + decisions: [], + cwd: directory, + signal: new AbortController().signal, + mutates: true, + ownedPaths: ["src"], + }); + assert.deepEqual(result.metadata.gitAfter?.changedPaths, ["src/generated.ts"]); + + const prototypePath = path.join(directory, "prototypes", "candidate-1"); + await mkdir(prototypePath, { recursive: true }); + await writeFile(path.join(prototypePath, "route.ts"), "prototype\n", "utf8"); + const cleanupExecutor = new PiAssignmentExecutor({ + modelRegistry: registry([inherited]), + parentCwd: directory, + parentProjectTrusted: false, + parentModel: inherited, + parentThinkingLevel: "medium" as ThinkingLevel, + concurrencyCeiling: 2, + runConcurrency: 1, + scheduler: new ProcessAssignmentScheduler(), + async createResources() { + return { + loader: {} as DefaultResourceLoader, + settingsManager: {} as SettingsManager, + }; + }, + async runAgent(options) { + assert.equal(options.tools?.includes("workflow_remove"), true); + const removeTool = options.customTools?.find( + (tool) => tool.name === "workflow_remove", + ); + assert.ok(removeTool); + await removeTool.execute( + "remove-prototype", + { path: "prototypes/candidate-1" }, + options.signal ?? new AbortController().signal, + undefined, + {} as ExtensionContext, + ); + return { + ok: true, + output: "", + structured: { + summary: "Removed prototype-only route", + changedPaths: ["prototypes/candidate-1"], + focusedTests: [], + }, + aborted: false, + usage: emptyUsage(), + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + childSessionId: "cleanup-child", + contextWindow: inherited.contextWindow, + transcript: [], + }; + }, + }); + await cleanupExecutor.run({ + runId: "run-cleanup", + role: "remediate", + request: "Remove prototype-only routes", + profile, + facts: { + stage: "implement", + taskKind: "feature", + requestTerms: ["cleanup"], + candidatePaths: ["prototypes/candidate-1"], + languages: ["typescript"], + tags: ["frontend"], + riskTags: [], + }, + decisions: [], + cwd: directory, + signal: new AbortController().signal, + mutates: true, + ownedPaths: ["prototypes/candidate-1"], + }); + await assert.rejects(access(prototypePath)); + + const reviewProfile = profiles.find((candidate) => candidate.roles.includes("review")); + assert.ok(reviewProfile); + const reviewRequest = { + runId: "run-review", + role: "review" as const, + request: "Review the feature", + profile: reviewProfile, + facts: { + stage: "review" as const, + taskKind: "feature" as const, + requestTerms: ["review"], + candidatePaths: ["src/generated.ts"], + languages: ["typescript"], + tags: [], + riskTags: [], + }, + decisions: [], + cwd: directory, + signal: new AbortController().signal, + captureGitState: true, + approvedPlan: "# Approved plan", + verificationEvidence: { + commands: [], + focusedBehavioralTests: true, + verifierSucceeded: true, + }, + reviewDiff: "a😀b", + }; + const reviewExecutor = ( + readCompleteDiff: boolean, + readCompletePlan = readCompleteDiff, + ) => + new PiAssignmentExecutor({ + modelRegistry: registry([inherited]), + parentCwd: directory, + parentProjectTrusted: false, + parentModel: inherited, + parentThinkingLevel: "medium" as ThinkingLevel, + concurrencyCeiling: 2, + runConcurrency: 1, + scheduler: new ProcessAssignmentScheduler(), + async createResources() { + return { + loader: {} as DefaultResourceLoader, + settingsManager: {} as SettingsManager, + }; + }, + async runAgent(options) { + assert.equal(options.tools?.includes("workflow_diff"), true); + assert.equal(options.tools?.includes("workflow_plan"), true); + assert.equal( + options.customTools?.some((tool) => tool.name === "workflow_diff"), + true, + ); + assert.equal( + options.customTools?.some((tool) => tool.name === "workflow_plan"), + true, + ); + const diffTool = options.customTools?.find( + (tool) => tool.name === "workflow_diff", + ); + assert.ok(diffTool); + const executeTool = diffTool.execute as unknown as ( + id: string, + params: { offset: number; limit: number }, + ) => Promise<{ content: Array<{ type: string; text: string }> }>; + const unicodePage = await executeTool("unicode-page", { + offset: 1, + limit: 1, + }); + assert.equal(unicodePage.content[0]?.text, "😀"); + return { + ok: true, + output: "", + structured: { + specification: { verdict: "approved", findings: [] }, + standards: { verdict: "approved", findings: [] }, + }, + aborted: false, + usage: emptyUsage(), + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + childSessionId: "review-child", + contextWindow: inherited.contextWindow, + transcript: [ + ...(readCompletePlan + ? [ + { + role: "tool" as const, + name: "workflow_plan", + toolCallId: "plan-call", + text: '{"offset":0,"limit":49152}', + }, + { + role: "toolResult" as const, + name: "workflow_plan", + toolCallId: "plan-call", + text: "plan page", + isError: false, + }, + ] + : []), + ...(readCompleteDiff + ? [ + { + role: "tool" as const, + name: "workflow_diff", + toolCallId: "diff-call", + text: '{"offset":0,"limit":49152}', + }, + { + role: "toolResult" as const, + name: "workflow_diff", + toolCallId: "diff-call", + text: "diff page", + isError: false, + }, + ] + : []), + ], + }; + }, + }); + await reviewExecutor(true).run(reviewRequest); + await assert.rejects( + reviewExecutor(false).run(reviewRequest), + /did not read the complete workflow diff/, + ); + await assert.rejects( + reviewExecutor(true, false).run(reviewRequest), + /did not read the complete approved workflow plan/, + ); + }); +}); + +test("production executor drives Discover and Design through Plan Approval", async () => { + await withTempDir(async (directory) => { + const profiles = ( + await loadAgentProfiles({ cwd: directory, projectTrusted: false }) + ).profiles; + const profileName = (role: "implement" | "verify" | "review") => { + const profile = profiles.find((candidate) => candidate.roles.includes(role)); + assert.ok(profile); + return profile.name; + }; + const inherited = model("provider-a", "parent"); + const models = registry([inherited]); + let sessions = 0; + const fakeRunAgent = async (options: RunAgentOptions) => { + sessions++; + options.onSessionCreated?.({ + childSessionId: `pipeline-child-${sessions}`, + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + }); + const structured = options.prompt.includes("Assignment role: discover") + ? { + questions: [], + candidatePaths: ["src/index.ts"], + languages: ["typescript"], + tags: ["backend"], + riskTags: [], + } + : { + plan: "# Plan\n\nImplement and verify the feature.", + uiImpact: "none", + uiImpactEvidence: ["No user-facing change"], + affectedUiSurfaces: [], + acceptanceCriteria: ["Feature behavior passes focused tests"], + verificationCommands: [ + { + executable: "npm", + args: ["test"], + ownedPaths: [], + kind: "test", + }, + ], + assignments: [ + { + role: "implement", + profileName: profileName("implement"), + rationale: "Implement the approved scope", + mutates: true, + ownedPaths: ["src"], + }, + { + role: "verify", + profileName: profileName("verify"), + rationale: "Run focused verification", + mutates: false, + ownedPaths: [], + }, + { + role: "review", + profileName: profileName("review"), + rationale: "Review independently", + mutates: false, + ownedPaths: [], + }, + ], + }; + return { + ok: true, + output: "", + structured, + aborted: false, + usage: emptyUsage(), + provider: inherited.provider, + model: inherited.id, + thinkingLevel: "medium" as ThinkingLevel, + childSessionId: `pipeline-child-${sessions}`, + contextWindow: inherited.contextWindow, + transcript: [{ role: "assistant" as const, text: "done" }], + }; + }; + const executor = new PiAssignmentExecutor({ + modelRegistry: models, + parentCwd: directory, + parentProjectTrusted: false, + parentModel: inherited, + parentThinkingLevel: "medium" as ThinkingLevel, + concurrencyCeiling: 4, + runConcurrency: 2, + scheduler: new ProcessAssignmentScheduler(), + runAgent: fakeRunAgent, + async createResources() { + return { + loader: {} as DefaultResourceLoader, + settingsManager: {} as SettingsManager, + }; + }, + }); + const workspace: WorkspaceManager = { + async prepare(options) { + return { + sourceRoot: options.sourceRoot, + worktreeRoot: path.join(options.sourceRoot, ".worktrees"), + worktreePath: path.join(options.sourceRoot, ".worktrees", options.runId), + branch: `feature/${options.featureName}`, + baseHead: "abc123", + branchExists: false, + }; + }, + async create(plan): Promise { + return { + state: "ready", + sourceRoot: plan.sourceRoot, + root: plan.worktreePath, + branch: plan.branch, + baseHead: plan.baseHead, + }; + }, + async reconcile(current) { + return current; + }, + }; + let id = 0; + const engine = new WorkflowEngine({ + store: new WorkflowStore(path.join(directory, "state")), + workspace, + executor, + sourceRoot: directory, + parentSessionId: "session-1", + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + profiles, + now: () => 10_000, + createId: () => `pipeline-${++id}`, + processId: 100, + isProcessAlive: () => true, + }); + const result = await engine.execute({ + action: "start", + request: "Build the feature", + featureName: "build-feature", + idempotencyKey: "pipeline-start", + }); + assert.equal(result.openGate?.kind, "plan-approval"); + assert.equal(sessions, 2); + assert.deepEqual( + result.assignments + .filter((assignment) => assignment.childSessionId) + .map((assignment) => assignment.childSessionId), + ["pipeline-child-1", "pipeline-child-2"], + ); + assert.deepEqual( + result.assignments + .filter((assignment) => assignment.status === "queued") + .map((assignment) => assignment.role), + ["implement", "verify", "review"], + ); + }); +}); diff --git a/extensions/pi-workflow/execution.ts b/extensions/pi-workflow/execution.ts new file mode 100644 index 0000000..191a035 --- /dev/null +++ b/extensions/pi-workflow/execution.ts @@ -0,0 +1,817 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { access, readFile } from "node:fs/promises"; +import { + createEditToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, + createLsToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, + defineTool, + type DefaultResourceLoader, + type ExtensionContext, + type SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { + createChildResources, + resolveStandaloneChildProjectTrust, +} from "../shared/child-session.ts"; +import { + runAgent, + type AgentOutcome, + type RunAgentOptions, + type ThinkingLevel, + type WorkflowModel, +} from "../_workflows/runner.ts"; +import { STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION } from "../_workflows/prompt.ts"; +import type { + AssignmentExecutionError, + AssignmentExecutionMetadata, + AssignmentExecutionResult, + AssignmentRequest, + AssignmentExecutor, +} from "./engine.ts"; +import { GitStateProbe, MutationScope } from "./workspace.ts"; + +const PROCESS_MAX_ASSIGNMENTS = 32; + +interface ScheduledAssignment { + runId: string; + runLimit: number; + exclusive: boolean; + signal: AbortSignal; + start: () => void; + reject: (error: Error) => void; + state: "queued" | "reserved" | "running" | "settled"; + onAbort: () => void; +} + +function abortError(signal: AbortSignal) { + return signal.reason instanceof Error + ? signal.reason + : new Error("Assignment was cancelled"); +} + +/** Process-wide capacity with synchronous reservation and cancellation-safe release. */ +export class ProcessAssignmentScheduler { + private ceiling = PROCESS_MAX_ASSIGNMENTS; + private inUse = 0; + private running = 0; + private readonly runUsage = new Map(); + private readonly exclusiveRuns = new Set(); + private readonly queue: ScheduledAssignment[] = []; + + lowerCeiling(ceiling: number) { + if (!Number.isInteger(ceiling) || ceiling < 1 || ceiling > PROCESS_MAX_ASSIGNMENTS) { + throw new Error(`Assignment ceiling must be between 1 and ${PROCESS_MAX_ASSIGNMENTS}`); + } + this.ceiling = Math.min(this.ceiling, ceiling); + this.drain(); + } + + schedule(options: { + runId: string; + runLimit: number; + signal: AbortSignal; + exclusive?: boolean; + onReserved?: () => Promise; + task: (signal: AbortSignal) => Promise; + }): Promise { + if (!Number.isInteger(options.runLimit) || options.runLimit < 1) { + return Promise.reject(new Error("Workflow Run assignment limit must be positive")); + } + if (options.signal.aborted) return Promise.reject(abortError(options.signal)); + + return new Promise((resolve, reject) => { + const entry: ScheduledAssignment = { + runId: options.runId, + runLimit: options.runLimit, + signal: options.signal, + exclusive: options.exclusive ?? false, + state: "queued", + reject, + onAbort: () => { + if (entry.state !== "queued") return; + entry.state = "settled"; + const index = this.queue.indexOf(entry); + if (index >= 0) this.queue.splice(index, 1); + options.signal.removeEventListener("abort", entry.onAbort); + reject(abortError(options.signal)); + }, + start: () => { + queueMicrotask(async () => { + if (entry.state !== "reserved") return; + entry.state = "running"; + this.running++; + try { + if (options.signal.aborted) throw abortError(options.signal); + await options.onReserved?.(); + if (options.signal.aborted) throw abortError(options.signal); + resolve(await options.task(options.signal)); + } catch (error) { + reject(error); + } finally { + this.running--; + this.release(entry); + } + }); + }, + }; + options.signal.addEventListener("abort", entry.onAbort, { once: true }); + this.queue.push(entry); + this.drain(); + }); + } + + snapshot() { + return { + ceiling: this.ceiling, + inUse: this.inUse, + running: this.running, + queued: this.queue.filter((entry) => entry.state === "queued").length, + }; + } + + private drain() { + while (this.inUse < this.ceiling) { + const index = this.queue.findIndex((entry, entryIndex) => { + if (entry.state !== "queued") return false; + const usage = this.runUsage.get(entry.runId) ?? 0; + if (entry.exclusive) return usage === 0; + if (this.exclusiveRuns.has(entry.runId) || usage >= entry.runLimit) { + return false; + } + return !this.queue + .slice(0, entryIndex) + .some( + (queued) => + queued.state === "queued" && + queued.runId === entry.runId && + queued.exclusive, + ); + }); + if (index < 0) return; + const entry = this.queue.splice(index, 1)[0]; + if (!entry || entry.signal.aborted) { + entry?.onAbort(); + continue; + } + entry.state = "reserved"; + this.inUse++; + this.runUsage.set(entry.runId, (this.runUsage.get(entry.runId) ?? 0) + 1); + if (entry.exclusive) this.exclusiveRuns.add(entry.runId); + entry.start(); + } + } + + private release(entry: ScheduledAssignment) { + if (entry.state === "settled") return; + entry.state = "settled"; + entry.signal.removeEventListener("abort", entry.onAbort); + this.inUse--; + if (entry.exclusive) this.exclusiveRuns.delete(entry.runId); + const remaining = (this.runUsage.get(entry.runId) ?? 1) - 1; + if (remaining === 0) this.runUsage.delete(entry.runId); + else this.runUsage.set(entry.runId, remaining); + this.drain(); + } +} + +export const processAssignmentScheduler = new ProcessAssignmentScheduler(); + +export function resolveWorkflowModel( + registry: ExtensionContext["modelRegistry"], + profile: { provider?: string; model?: string }, + inherited: WorkflowModel | undefined, +): Model | undefined { + if (profile.provider && !profile.model) { + throw new Error(`Agent Profile provider ${profile.provider} requires a model`); + } + if (!profile.model) { + if (!inherited) return undefined; + const found = registry.find(inherited.provider, inherited.id); + if (!found) { + throw new Error( + `Unknown inherited model ${inherited.provider}/${inherited.id}`, + ); + } + return found; + } + if (profile.provider) { + const found = registry.find(profile.provider, profile.model); + if (!found) throw new Error(`Unknown model ${profile.provider}/${profile.model}`); + return found; + } + const slash = profile.model.indexOf("/"); + if (slash > 0) { + const provider = profile.model.slice(0, slash); + const model = profile.model.slice(slash + 1); + const found = registry.find(provider, model); + if (!found) throw new Error(`Unknown model ${profile.model}`); + return found; + } + if (inherited) { + const found = registry.find(inherited.provider, profile.model); + if (found) return found; + } + const matches = registry.getAll().filter((model) => model.id === profile.model); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + throw new Error( + `Model ${profile.model} exists in multiple providers; use provider/model`, + ); + } + throw new Error(`Unknown model ${profile.model}`); +} + +const DISCOVERY_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "questions", + "candidatePaths", + "languages", + "tags", + "riskTags", + ], + properties: { + questions: { + type: "array", + maxItems: 20, + items: { + type: "object", + additionalProperties: false, + required: ["id", "prompt"], + properties: { + id: { type: "string", maxLength: 160 }, + prompt: { type: "string", maxLength: 2000 }, + }, + }, + }, + candidatePaths: { type: "array", items: { type: "string" } }, + languages: { type: "array", items: { type: "string" } }, + tags: { type: "array", items: { type: "string" } }, + riskTags: { type: "array", items: { type: "string" } }, + }, +} as const; + +const DESIGN_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "plan", + "uiImpact", + "uiImpactEvidence", + "affectedUiSurfaces", + "assignments", + "acceptanceCriteria", + "verificationCommands", + ], + properties: { + plan: { type: "string" }, + uiImpact: { enum: ["none", "minor", "non-trivial"] }, + uiImpactEvidence: { type: "array", items: { type: "string" } }, + affectedUiSurfaces: { type: "array", items: { type: "string" } }, + acceptanceCriteria: { type: "array", items: { type: "string" } }, + verificationCommands: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["executable", "args", "ownedPaths", "kind"], + properties: { + executable: { type: "string" }, + args: { type: "array", items: { type: "string" } }, + ownedPaths: { type: "array", items: { type: "string" } }, + kind: { + enum: ["test", "typecheck", "lint", "build", "browser", "generation"], + }, + }, + }, + }, + assignments: { + type: "array", + minItems: 3, + items: { + type: "object", + additionalProperties: false, + required: ["role", "profileName", "rationale", "mutates", "ownedPaths"], + properties: { + role: { enum: ["prototype", "implement", "verify", "review"] }, + profileName: { type: "string" }, + rationale: { type: "string" }, + mutates: { type: "boolean" }, + ownedPaths: { type: "array", items: { type: "string" } }, + }, + }, + }, + }, +} as const; + +const PROTOTYPE_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "path", + "route", + "structuralRationale", + "responsiveChecks", + "browserEvidence", + "launchInstructions", + ], + properties: { + path: { type: "string" }, + route: { type: "string" }, + structuralRationale: { type: "string" }, + responsiveChecks: { type: "array", items: { type: "string" } }, + browserEvidence: { type: "array", items: { type: "string" } }, + launchInstructions: { type: "string" }, + }, +} as const; + +const IMPLEMENTATION_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["summary", "changedPaths", "focusedTests"], + properties: { + summary: { type: "string" }, + changedPaths: { type: "array", items: { type: "string" } }, + focusedTests: { type: "array", items: { type: "string" } }, + }, +} as const; + +const VERIFICATION_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["succeeded", "focusedBehavioralTests", "summary"], + properties: { + succeeded: { type: "boolean" }, + focusedBehavioralTests: { type: "boolean" }, + summary: { type: "string" }, + }, +} as const; + +const REVIEW_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["specification", "standards"], + properties: { + specification: { $ref: "#/$defs/axis" }, + standards: { $ref: "#/$defs/axis" }, + }, + $defs: { + axis: { + type: "object", + additionalProperties: false, + required: ["verdict", "findings"], + properties: { + verdict: { enum: ["approved", "changes_requested"] }, + findings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["severity", "message"], + properties: { + severity: { type: "string" }, + message: { type: "string" }, + path: { type: "string" }, + }, + }, + }, + }, + }, + }, +} as const; + +function schemaForRole(role: AssignmentRequest["role"]) { + if (role === "discover") return DISCOVERY_SCHEMA; + if (role === "design") return DESIGN_SCHEMA; + if (role === "prototype") return PROTOTYPE_SCHEMA; + if (role === "verify") return VERIFICATION_SCHEMA; + if (role === "review") return REVIEW_SCHEMA; + return IMPLEMENTATION_SCHEMA; +} + +function pagedTextTool(options: { + name: "workflow_diff" | "workflow_plan"; + label: string; + description: string; + contents: string; +}) { + const characters = [...options.contents]; + return defineTool({ + name: options.name, + label: options.label, + description: options.description, + parameters: Type.Object({ + offset: Type.Optional(Type.Integer({ minimum: 0 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 48 * 1024 })), + }), + async execute(_id, params) { + const offset = params.offset ?? 0; + const limit = params.limit ?? 48 * 1024; + return { + content: [ + { + type: "text" as const, + text: characters.slice(offset, offset + limit).join(""), + }, + ], + details: { offset, limit, totalCharacters: characters.length }, + }; + }, + }); +} + +function readCompletePagedText( + transcript: AssignmentExecutionMetadata["transcript"], + toolName: "workflow_diff" | "workflow_plan", + totalCharacters: number, +) { + if (totalCharacters === 0) return true; + const entries = transcript.filter( + ( + entry, + ): entry is { + role: string; + name?: string; + text: string; + toolCallId?: string; + isError?: boolean; + } => + typeof entry === "object" && + entry !== null && + "role" in entry && + typeof entry.role === "string" && + "text" in entry && + typeof entry.text === "string", + ); + const successfulCallIds = new Set( + entries + .filter( + (entry) => + entry.role === "toolResult" && + entry.name === toolName && + !entry.isError && + typeof entry.toolCallId === "string", + ) + .map((entry) => entry.toolCallId!), + ); + const intervals = entries + .filter( + (entry) => + entry.role === "tool" && + typeof entry.toolCallId === "string" && + successfulCallIds.has(entry.toolCallId) && + entry.name === toolName, + ) + .map((entry) => { + try { + const input = JSON.parse(entry.text) as { offset?: unknown; limit?: unknown }; + const offset = input.offset === undefined ? 0 : input.offset; + const limit = input.limit === undefined ? 48 * 1024 : input.limit; + if ( + typeof offset !== "number" || + !Number.isInteger(offset) || + offset < 0 || + typeof limit !== "number" || + !Number.isInteger(limit) || + limit < 1 || + limit > 48 * 1024 + ) { + return [0, 0] as const; + } + return [offset, Math.min(totalCharacters, offset + limit)] as const; + } catch { + return [0, 0] as const; + } + }) + .sort((left, right) => left[0] - right[0]); + let covered = 0; + for (const [start, end] of intervals) { + if (start > covered) return false; + covered = Math.max(covered, end); + if (covered >= totalCharacters) return true; + } + return false; +} + +function assignmentPrompt(request: AssignmentRequest) { + const plannerCandidates = request.plannerCandidates?.map((entry) => ({ + role: entry.role, + candidates: entry.candidates + .filter((candidate) => candidate.eligible) + .map((candidate) => ({ + name: candidate.profileName, + digest: candidate.profileDigest, + specificity: candidate.specificity, + })), + })); + return [ + `Assignment role: ${request.role}`, + `Workflow request: ${request.request}`, + `Repository facts: ${JSON.stringify(request.facts)}`, + `Accepted decisions: ${JSON.stringify(request.decisions.map((item) => item.value))}`, + ...(plannerCandidates + ? [`Eligible planner candidates: ${JSON.stringify(plannerCandidates)}`] + : []), + ...(request.approvedPlan + ? request.role === "verify" || request.role === "review" + ? [ + `Approved plan: ${[...request.approvedPlan].length} characters available through workflow_plan. Read the complete plan.`, + ] + : [`Approved plan:\n${request.approvedPlan}`] + : []), + ...(request.verificationEvidence + ? [ + `Persisted verification evidence: ${JSON.stringify(request.verificationEvidence)}`, + ] + : []), + ...(request.reviewDiff + ? [ + `Review diff: ${[...request.reviewDiff].length} characters available through workflow_diff. Review the complete scope.`, + ] + : []), + "Follow the active Agent Profile instructions. Return only through structured_output.", + ].join("\n\n"); +} + +interface ChildResources { + loader: DefaultResourceLoader; + settingsManager: SettingsManager; +} + +export interface PiAssignmentExecutorOptions { + modelRegistry: ExtensionContext["modelRegistry"]; + parentCwd: string; + parentProjectTrusted: boolean; + parentModel?: WorkflowModel; + parentThinkingLevel: ThinkingLevel; + concurrencyCeiling: number; + runConcurrency: number; + scheduler?: ProcessAssignmentScheduler; + runAgent?: (options: RunAgentOptions) => Promise; + createResources?: (options: { + cwd: string; + projectTrusted: boolean; + appendSystemPrompt: string[]; + }) => Promise; + resolveProjectTrust?: typeof resolveStandaloneChildProjectTrust; +} + +export class PiAssignmentExecutor implements AssignmentExecutor { + readonly reservesCapacity = true; + private readonly options: PiAssignmentExecutorOptions; + private readonly scheduler: ProcessAssignmentScheduler; + + constructor(options: PiAssignmentExecutorOptions) { + this.options = options; + this.scheduler = options.scheduler ?? processAssignmentScheduler; + this.scheduler.lowerCeiling(options.concurrencyCeiling); + } + + async run(request: AssignmentRequest): Promise { + const model = resolveWorkflowModel( + this.options.modelRegistry, + request.profile, + this.options.parentModel, + ) as WorkflowModel | undefined; + const thinkingLevel = + (request.profile.effort as ThinkingLevel | undefined) ?? + this.options.parentThinkingLevel; + const resolveTrust = + this.options.resolveProjectTrust ?? resolveStandaloneChildProjectTrust; + const projectTrusted = resolveTrust({ + parentCwd: this.options.parentCwd, + childCwd: request.cwd, + parentTrusted: this.options.parentProjectTrusted, + }); + const createResources = this.options.createResources ?? createChildResources; + const resources = await createResources({ + cwd: request.cwd, + projectTrusted, + appendSystemPrompt: [ + request.profile.instructions, + STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION, + ], + }); + const execute = this.options.runAgent ?? runAgent; + const mutationScope = request.mutates + ? await MutationScope.create(request.cwd, request.ownedPaths ?? []) + : undefined; + const gitProbe = + mutationScope || request.captureGitState ? new GitStateProbe() : undefined; + const evidenceTools = [ + ...(request.reviewDiff + ? [ + pagedTextTool({ + name: "workflow_diff", + label: "Workflow Diff", + description: + "Read the complete review diff in bounded character ranges.", + contents: request.reviewDiff, + }), + ] + : []), + ...(request.approvedPlan && + (request.role === "verify" || request.role === "review") + ? [ + pagedTextTool({ + name: "workflow_plan", + label: "Workflow Plan", + description: + "Read the complete approved plan in bounded character ranges.", + contents: request.approvedPlan, + }), + ] + : []), + ]; + const allowedReadTools = new Set( + request.allowedReadTools ?? ["read", "grep", "find", "ls"], + ); + const auditedReadTools = [ + createReadToolDefinition(request.cwd), + createGrepToolDefinition(request.cwd), + createFindToolDefinition(request.cwd), + createLsToolDefinition(request.cwd), + ].filter((tool) => allowedReadTools.has(tool.name)); + const mutationTools = mutationScope + ? [ + createEditToolDefinition(request.cwd, { + operations: { + async readFile(absolutePath) { + return readFile(await mutationScope.assertWritePath(absolutePath)); + }, + async writeFile(absolutePath, content) { + await mutationScope.writeFile(absolutePath, content); + }, + async access(absolutePath) { + await access(await mutationScope.assertWritePath(absolutePath)); + }, + }, + }), + createWriteToolDefinition(request.cwd, { + operations: { + async writeFile(absolutePath, content) { + await mutationScope.writeFile(absolutePath, content); + }, + async mkdir(directory) { + await mutationScope.mkdir(directory); + }, + }, + }), + ] + : []; + const removalTools = + mutationScope && request.role === "remediate" + ? [ + defineTool({ + name: "workflow_remove", + label: "Remove Owned Prototype Path", + description: + "Remove a prototype-only file or directory inside Assignment ownership.", + parameters: Type.Object({ path: Type.String() }), + async execute(_id, params) { + await mutationScope.remove(params.path); + return { + content: [ + { type: "text" as const, text: `Removed ${params.path}` }, + ], + details: { path: params.path }, + }; + }, + }), + ] + : []; + return this.scheduler.schedule({ + runId: request.runId, + runLimit: request.runConcurrency ?? this.options.runConcurrency, + signal: request.signal, + exclusive: request.mutates, + onReserved: request.onReserved, + task: async (signal) => { + let created: AssignmentExecutionMetadata | undefined; + const gitBefore = gitProbe + ? await gitProbe.capture(mutationScope?.root ?? request.cwd) + : undefined; + const outcome = await execute({ + prompt: assignmentPrompt(request), + schema: schemaForRole(request.role), + model, + thinkingLevel, + cwd: request.cwd, + loader: resources.loader, + settingsManager: resources.settingsManager, + modelRegistry: this.options.modelRegistry, + signal, + tools: request.mutates + ? [ + ...(request.profile.tools ?? []).filter((tool) => + (["edit", "write"].includes(tool) || + allowedReadTools.has(tool)), + ), + ...evidenceTools.map((tool) => tool.name), + ...removalTools.map((tool) => tool.name), + "structured_output", + ] + : [ + ...(request.profile.tools ?? []).filter((tool) => + allowedReadTools.has(tool), + ), + ...evidenceTools.map((tool) => tool.name), + "structured_output", + ], + customTools: [ + ...auditedReadTools, + ...mutationTools, + ...removalTools, + ...evidenceTools, + ], + onSessionCreated(metadata) { + created = { + childSessionId: metadata.childSessionId, + provider: metadata.provider, + model: metadata.model, + effort: metadata.thinkingLevel, + contextCapacity: undefined, + transcript: [], + }; + }, + }); + const gitAfter = gitProbe + ? await gitProbe.capture(mutationScope?.root ?? request.cwd) + : undefined; + let safetyError: Error | undefined; + if ( + request.role === "review" && + request.reviewDiff !== undefined && + !readCompletePagedText( + outcome.transcript, + "workflow_diff", + [...request.reviewDiff].length, + ) + ) { + safetyError = new Error( + "Review Assignment did not read the complete workflow diff", + ); + } + if ( + !safetyError && + request.approvedPlan !== undefined && + (request.role === "verify" || request.role === "review") && + !readCompletePagedText( + outcome.transcript, + "workflow_plan", + [...request.approvedPlan].length, + ) + ) { + safetyError = new Error( + "Assignment did not read the complete approved workflow plan", + ); + } + if (!safetyError && gitProbe && gitBefore && gitAfter) { + try { + gitProbe.assertIdentity(gitBefore, gitAfter); + const touched = gitProbe.changedSince(gitBefore, gitAfter); + const outOfScope = mutationScope + ? touched.filter((changed) => !mutationScope.owns(changed)) + : touched; + if (outOfScope.length > 0) { + throw new Error( + request.captureGitState && !mutationScope + ? `Read-only Assignment changed the repository: ${outOfScope.join(", ")}` + : `Assignment changed paths outside ownership: ${outOfScope.join(", ")}`, + ); + } + } catch (error) { + safetyError = error instanceof Error ? error : new Error(String(error)); + } + } + const metadata: AssignmentExecutionMetadata = { + childSessionId: outcome.childSessionId ?? created?.childSessionId, + provider: outcome.provider ?? created?.provider, + model: outcome.model ?? created?.model, + effort: outcome.thinkingLevel ?? created?.effort ?? thinkingLevel, + contextCapacity: outcome.contextWindow, + transcript: outcome.transcript, + gitBefore, + gitAfter, + }; + if (safetyError || !outcome.ok) { + const error = new Error( + safetyError?.message ?? outcome.error ?? "Pi Assignment failed", + ) as AssignmentExecutionError; + error.assignmentMetadata = metadata; + throw error; + } + return { + kind: "pi-workflow-assignment-result", + output: outcome.structured, + metadata, + }; + }, + }); + } +} diff --git a/extensions/pi-workflow/index.test.ts b/extensions/pi-workflow/index.test.ts new file mode 100644 index 0000000..18fdedc --- /dev/null +++ b/extensions/pi-workflow/index.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { registerWorkflowRecovery } from "./index.ts"; +import type { ContextTransitionRuntime } from "./context-transition.ts"; + + +test("session startup invokes persisted Workflow recovery", async () => { + let sessionStart: + | ((event: { reason: string }, ctx: ExtensionContext) => Promise) + | undefined; + const pi = { + on(event: string, handler: typeof sessionStart) { + if (event === "session_start") sessionStart = handler; + }, + } as unknown as ExtensionAPI; + let recoveries = 0; + registerWorkflowRecovery(pi, { + store: {} as never, + engineForContext: async () => + ({ + async recover() { + recoveries++; + return []; + }, + }) as never, + } as ContextTransitionRuntime); + assert.ok(sessionStart); + await sessionStart( + { reason: "startup" }, + { + ui: { notify: () => {} }, + } as unknown as ExtensionContext, + ); + assert.equal(recoveries, 1); +}); diff --git a/extensions/pi-workflow/index.ts b/extensions/pi-workflow/index.ts new file mode 100644 index 0000000..c6c46e1 --- /dev/null +++ b/extensions/pi-workflow/index.ts @@ -0,0 +1,123 @@ +import * as path from "node:path"; +import { + getAgentDir, + type ExtensionAPI, + type ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { loadWorkflowConfig } from "./config.ts"; +import type { WorkflowRun } from "./domain.ts"; +import { openWorkflowDashboard } from "./dashboard.ts"; +import { + registerWorkflowContextTransitions, + type ContextTransitionRuntime, +} from "./context-transition.ts"; +import { WorkflowEngine } from "./engine.ts"; +import { + PiAssignmentExecutor, + type ProcessAssignmentScheduler, +} from "./execution.ts"; +import { loadAgentProfiles } from "./profiles.ts"; +import { + reconcileWorkflowDeliveries, + registerWorkflowNotifications, +} from "./notifications.ts"; +import { runScriptSandbox } from "./script-runtime.ts"; +import { WorkflowStore } from "./store.ts"; +import { registerPiWorkflowTool } from "./tool.ts"; +import { + ApprovedHostCommandExecutor, + FilesystemPrototypeValidator, + GitDiffProvider, + GitWorkspaceManager, +} from "./workspace.ts"; +import type { ThinkingLevel, WorkflowModel } from "../_workflows/runner.ts"; + +export interface WorkflowExtensionOptions { + stateRoot?: string; + scheduler?: ProcessAssignmentScheduler; + afterRunPersist?: ( + ctx: ExtensionContext, + run: WorkflowRun, + ) => void | Promise; +} + +export function createWorkflowRuntime( + options: WorkflowExtensionOptions = {}, +): ContextTransitionRuntime { + const store = new WorkflowStore( + options.stateRoot ?? path.join(getAgentDir(), "pi-workflows"), + ); + return { + store, + async engineForContext(ctx: ExtensionContext) { + const config = await loadWorkflowConfig({ + cwd: ctx.cwd, + projectTrusted: ctx.isProjectTrusted(), + }); + const catalog = await loadAgentProfiles({ + cwd: ctx.cwd, + projectTrusted: ctx.isProjectTrusted(), + }); + const executor = new PiAssignmentExecutor({ + modelRegistry: ctx.modelRegistry, + parentCwd: ctx.cwd, + parentProjectTrusted: ctx.isProjectTrusted(), + parentModel: ctx.model as WorkflowModel | undefined, + parentThinkingLevel: (ctx.thinkingLevel ?? "off") as ThinkingLevel, + concurrencyCeiling: config.concurrency.ceiling, + runConcurrency: config.concurrency.default, + ...(options.scheduler ? { scheduler: options.scheduler } : {}), + }); + return new WorkflowEngine({ + store, + workspace: new GitWorkspaceManager(), + executor, + hostCommands: new ApprovedHostCommandExecutor(), + reviewDiff: new GitDiffProvider(), + scriptRuntime: { run: runScriptSandbox }, + prototypeValidator: new FilesystemPrototypeValidator(), + sourceRoot: ctx.cwd, + parentSessionId: ctx.sessionManager.getSessionId(), + effectiveConfig: config, + profiles: catalog.profiles, + ...(options.afterRunPersist + ? { + afterRunPersist: (run) => options.afterRunPersist!(ctx, run), + } + : {}), + }); + }, + }; +} + +export function registerWorkflowRecovery( + pi: ExtensionAPI, + runtime: ContextTransitionRuntime, +) { + pi.on("session_start", async (_event, ctx) => { + try { + const engine = await runtime.engineForContext(ctx); + await engine.recover(); + } catch (error) { + ctx.ui.notify( + `pi-workflow startup recovery failed: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } + }); +} + +export default function piWorkflowExtension(pi: ExtensionAPI) { + let runtime: ContextTransitionRuntime; + runtime = createWorkflowRuntime({ + afterRunPersist: async (ctx) => { + await reconcileWorkflowDeliveries(pi, runtime, ctx); + }, + }); + registerWorkflowContextTransitions(pi, runtime, (args, ctx) => + openWorkflowDashboard(pi, runtime, ctx, args || undefined), + ); + registerPiWorkflowTool(pi, runtime); + registerWorkflowRecovery(pi, runtime); + registerWorkflowNotifications(pi, runtime); +} diff --git a/extensions/pi-workflow/notifications.test.ts b/extensions/pi-workflow/notifications.test.ts new file mode 100644 index 0000000..136e481 --- /dev/null +++ b/extensions/pi-workflow/notifications.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { + DEFAULT_WORKFLOW_CONFIG, + type WorkflowRun, +} from "./domain.ts"; +import { + reconcileWorkflowDeliveries, + workflowNotificationType, +} from "./notifications.ts"; +import { WorkflowStore } from "./store.ts"; + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-delivery-")); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function workflow(background = false): WorkflowRun { + const gateId = "gate-1"; + return { + runId: "run-1", + revision: 0, + mode: "feature", + status: "waiting", + background, + request: "Build search", + featureName: "search", + parentSessionIds: ["session-1"], + lease: { + processId: 100, + activeParentSessionId: "session-1", + heartbeatAt: 1, + }, + workspace: { + state: "ready", + sourceRoot: "/repo", + root: "/repo/.worktrees/run-1", + branch: "feature/search", + baseHead: "abc123", + }, + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + pinnedProfiles: [], + preflightEligibility: [], + facts: { + stage: "design", + taskKind: "feature", + requestTerms: ["search"], + candidatePaths: [], + languages: [], + tags: [], + riskTags: [], + }, + stages: [], + gates: [ + { + id: gateId, + kind: "plan-approval", + status: "open", + revision: 0, + openedAt: 1, + }, + ], + decisions: [], + assignments: [], + prototypeCandidates: [], + prototypeCleanupCompleted: false, + deliveries: [ + { + key: `gate:run-1:${gateId}:0`, + state: "pending", + createdAt: 1, + }, + ], + commandReceipts: [], + uiImpactEvidence: [], + affectedUiSurfaces: [], + mutatorProfileNames: [], + reviewCycles: 0, + verificationCycles: 0, + }; +} + +function deliveryHarness(entries: unknown[], persistImmediately = true) { + const sent: Array<{ message: unknown; options: unknown }> = []; + const pi = { + sendMessage(message: unknown, options: unknown) { + sent.push({ message, options }); + if (persistImmediately) { + const record = message as Record; + entries.push({ + type: "message", + message: { + role: "custom", + customType: record.customType, + details: record.details, + }, + }); + } + }, + } as unknown as Pick; + const ctx = { + sessionManager: { + getSessionId: () => "session-1", + getEntries: () => entries, + }, + } as unknown as ExtensionContext; + return { pi, ctx, sent }; +} + +test("delivery receipt closes the send/save crash window exactly once", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + await store.createRun(workflow()); + const entries: unknown[] = []; + const harness = deliveryHarness(entries); + await reconcileWorkflowDeliveries( + harness.pi, + { store, engineForContext: () => Promise.reject(new Error("unused")) }, + harness.ctx, + ); + assert.equal(harness.sent.length, 1); + assert.equal( + (harness.sent[0]?.message as Record).customType, + workflowNotificationType, + ); + const delivered = await store.loadRun("run-1"); + assert.equal(delivered.deliveries[0]?.state, "sent"); + assert.equal(delivered.revision, 0); + await reconcileWorkflowDeliveries( + harness.pi, + { store, engineForContext: () => Promise.reject(new Error("unused")) }, + harness.ctx, + ); + assert.equal(harness.sent.length, 1); + }); +}); + +test("queued foreground delivery suppresses immediate duplicates and retries a missing receipt", async () => { + await withTempDir(async (directory) => { + const originalNow = Date.now; + let now = 1_000; + Date.now = () => now; + try { + const store = new WorkflowStore(path.join(directory, "state")); + const run = workflow(); + await store.createRun(run); + const entries: unknown[] = []; + const harness = deliveryHarness(entries, false); + const runtime = { + store, + engineForContext: () => Promise.reject(new Error("unused")), + }; + await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx); + now++; + await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx); + assert.equal(harness.sent.length, 1); + assert.deepEqual(harness.sent[0]?.options, { deliverAs: "steer" }); + now += 30_000; + await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx); + assert.equal(harness.sent.length, 2); + const sentMessage = harness.sent[1]?.message as Record; + entries.push({ + type: "message", + message: { + role: "custom", + customType: sentMessage.customType, + details: sentMessage.details, + }, + }); + await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx); + assert.equal(harness.sent.length, 2); + assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent"); + } finally { + Date.now = originalNow; + } + }); +}); + +test("obsolete Gate deliveries settle without sending contradictory notices", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + const run = workflow(); + run.status = "running"; + run.gates[0]!.status = "satisfied"; + await store.createRun(run); + const harness = deliveryHarness([]); + await reconcileWorkflowDeliveries( + harness.pi, + { store, engineForContext: () => Promise.reject(new Error("unused")) }, + harness.ctx, + ); + assert.equal(harness.sent.length, 0); + assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent"); + }); +}); + +test("an existing receiver receipt marks pending delivery without resending", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + const run = workflow(true); + await store.createRun(run); + const entries = [ + { + type: "message", + message: { + role: "custom", + customType: workflowNotificationType, + details: { deliveryKey: run.deliveries[0]?.key }, + }, + }, + ]; + const harness = deliveryHarness(entries); + await reconcileWorkflowDeliveries( + harness.pi, + { store, engineForContext: () => Promise.reject(new Error("unused")) }, + harness.ctx, + ); + assert.equal(harness.sent.length, 0); + assert.equal( + (await store.loadRun("run-1")).deliveries[0]?.state, + "sent", + ); + }); +}); + +test("terminal deliveries include the persisted report and settle once", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + const run = workflow(true); + run.status = "completed"; + run.gates = []; + run.reportArtifact = { + path: "/state/report.md", + digest: "digest", + revision: 4, + }; + run.deliveries = [ + { + key: "terminal:run-1:completed:4", + state: "pending", + createdAt: 4, + }, + ]; + await store.createRun(run); + const harness = deliveryHarness([]); + await reconcileWorkflowDeliveries( + harness.pi, + { store, engineForContext: () => Promise.reject(new Error("unused")) }, + harness.ctx, + ); + assert.equal(harness.sent.length, 1); + assert.match( + String((harness.sent[0]?.message as Record).content), + /finished with status completed.*Report: \/state\/report.md/, + ); + assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent"); + }); +}); + +test("background deliveries request a follow-up turn", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + await store.createRun(workflow(true)); + const harness = deliveryHarness([]); + await reconcileWorkflowDeliveries( + harness.pi, + { store, engineForContext: () => Promise.reject(new Error("unused")) }, + harness.ctx, + ); + assert.deepEqual(harness.sent[0]?.options, { + deliverAs: "followUp", + triggerTurn: true, + }); + }); +}); diff --git a/extensions/pi-workflow/notifications.ts b/extensions/pi-workflow/notifications.ts new file mode 100644 index 0000000..e97fab5 --- /dev/null +++ b/extensions/pi-workflow/notifications.ts @@ -0,0 +1,207 @@ +import { randomUUID } from "node:crypto"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { WorkflowRun } from "./domain.ts"; +import type { ContextTransitionRuntime } from "./context-transition.ts"; + +const NOTIFICATION_TYPE = "pi-workflow-notification"; +const DELIVERY_OWNER_TOKEN = `${process.pid}:${randomUUID()}`; +const ACTIVE_DELIVERIES = new Map< + string, + { scheduledAt: number; runId: string } +>(); +const DELIVERY_RETRY_MS = 30_000; + +function receiptKeys(ctx: ExtensionContext) { + const keys = new Set(); + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type !== "message") continue; + const message = entry.message as unknown as Record; + if (message.role !== "custom" || message.customType !== NOTIFICATION_TYPE) { + continue; + } + const details = message.details; + if (!details || typeof details !== "object") continue; + const key = (details as Record).deliveryKey; + if (typeof key === "string") keys.add(key); + } + return keys; +} + +function deliveryIsCurrent(run: WorkflowRun, key: string) { + if (key.startsWith("gate:")) { + const gate = [...run.gates] + .reverse() + .find((candidate) => key.includes(`:${candidate.id}:`)); + const activeGate = [...run.gates] + .reverse() + .find((candidate) => candidate.status === "open"); + return Boolean(gate && gate.status === "open" && activeGate?.id === gate.id); + } + if (key.startsWith("terminal:")) { + return key.includes(`:${run.status}:`) && + (run.status === "completed" || + run.status === "failed" || + run.status === "aborted"); + } + return false; +} + +function deliveryText(run: WorkflowRun, key: string) { + if (key.startsWith("gate:")) { + const gate = [...run.gates] + .reverse() + .find((candidate) => key.includes(`:${candidate.id}:`)); + return gate + ? `pi-workflow ${run.runId} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.` + : `pi-workflow ${run.runId} is waiting for a Gate Decision.`; + } + const report = run.reportArtifact?.path + ? ` Report: ${run.reportArtifact.path}` + : ""; + return `pi-workflow ${run.runId} finished with status ${run.status}.${report}`; +} + +function belongsToSession(run: WorkflowRun, sessionId: string) { + const activeSession = + run.lease?.activeParentSessionId ?? run.parentSessionIds.at(-1); + return activeSession === sessionId; +} + +export async function reconcileWorkflowDeliveries( + pi: Pick, + runtime: ContextTransitionRuntime, + ctx: ExtensionContext, +) { + const sessionId = ctx.sessionManager.getSessionId(); + if (!sessionId) return; + let receipts = receiptKeys(ctx); + for (const run of await runtime.store.listRuns()) { + if (!belongsToSession(run, sessionId)) continue; + if ( + (run.status === "completed" || + run.status === "failed" || + run.status === "aborted") && + run.reportArtifact + ) { + await runtime.store.ensureResult(run); + } + for (const delivery of run.deliveries) { + if (delivery.state === "sent") continue; + if (!deliveryIsCurrent(run, delivery.key)) { + ACTIVE_DELIVERIES.delete(delivery.key); + await runtime.store.clearDeliveryClaim(run.runId, delivery.key); + await runtime.store.markDeliverySent( + run.runId, + delivery.key, + Date.now(), + ); + continue; + } + if (receipts.has(delivery.key)) { + ACTIVE_DELIVERIES.delete(delivery.key); + await runtime.store.clearDeliveryClaim(run.runId, delivery.key); + await runtime.store.markDeliverySent( + run.runId, + delivery.key, + Date.now(), + ); + continue; + } + const active = ACTIVE_DELIVERIES.get(delivery.key); + if ( + active && + Date.now() - active.scheduledAt < DELIVERY_RETRY_MS + ) { + continue; + } + const claimedAt = Date.now(); + const claimed = await runtime.store.claimDelivery( + run.runId, + delivery.key, + DELIVERY_OWNER_TOKEN, + claimedAt, + ); + if (!claimed) continue; + ACTIVE_DELIVERIES.set(delivery.key, { + scheduledAt: claimedAt, + runId: run.runId, + }); + try { + pi.sendMessage( + { + customType: NOTIFICATION_TYPE, + content: deliveryText(run, delivery.key), + display: true, + details: { + deliveryKey: delivery.key, + runId: run.runId, + revision: run.revision, + status: run.status, + reportPath: run.reportArtifact?.path, + }, + }, + run.background + ? { deliverAs: "followUp", triggerTurn: true } + : { deliverAs: "steer" }, + ); + receipts = receiptKeys(ctx); + if (receipts.has(delivery.key)) { + ACTIVE_DELIVERIES.delete(delivery.key); + await runtime.store.clearDeliveryClaim(run.runId, delivery.key); + await runtime.store.markDeliverySent( + run.runId, + delivery.key, + Date.now(), + ); + } + } catch (error) { + ACTIVE_DELIVERIES.delete(delivery.key); + await runtime.store.releaseDeliveryClaim( + run.runId, + delivery.key, + DELIVERY_OWNER_TOKEN, + ); + throw error; + } + } + } +} + +export function registerWorkflowNotifications( + pi: ExtensionAPI, + runtime: ContextTransitionRuntime, +) { + const reconcile = async (ctx: ExtensionContext) => { + try { + await reconcileWorkflowDeliveries(pi, runtime, ctx); + } catch (error) { + ctx.ui.notify( + `pi-workflow delivery recovery failed: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + } + }; + pi.on("session_start", async (_event, ctx) => reconcile(ctx)); + pi.on("agent_settled", async (_event, ctx) => reconcile(ctx)); + pi.on("tool_result", async (event, ctx) => { + if (event.toolName === "pi_workflow") await reconcile(ctx); + }); + pi.on("session_shutdown", async () => { + const active = [...ACTIVE_DELIVERIES.entries()]; + ACTIVE_DELIVERIES.clear(); + await Promise.all( + active.map(([key, delivery]) => + runtime.store.releaseDeliveryClaim( + delivery.runId, + key, + DELIVERY_OWNER_TOKEN, + ), + ), + ); + }); +} + +export const workflowNotificationType = NOTIFICATION_TYPE; diff --git a/extensions/pi-workflow/profiles.test.ts b/extensions/pi-workflow/profiles.test.ts new file mode 100644 index 0000000..cec9020 --- /dev/null +++ b/extensions/pi-workflow/profiles.test.ts @@ -0,0 +1,494 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import type { AgentProfile, WorkflowFacts } from "./domain.ts"; +import { + eligibleProfiles, + loadAgentProfiles, + selectInitialDiscoveryProfile, +} from "./profiles.ts"; + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-profiles-")); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function profileDocument(options: { + name: string; + description?: string; + roles?: string; + selection?: string; + extra?: string; + body?: string; +}) { + return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\nroles: ${options.roles ?? "[discover]"}\n${options.extra ?? ""}selection:\n${options.selection ?? " stages: [discover]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n"}---\n\n${options.body ?? "Follow the assignment."}\n`; +} + +async function writeProfile( + root: string, + file: string, + options: Parameters[0], +) { + await mkdir(root, { recursive: true }); + await writeFile(path.join(root, file), profileDocument(options)); +} + +test("bundled profiles form a complete deterministic catalog", async () => { + const catalog = await loadAgentProfiles({ + cwd: process.cwd(), + projectTrusted: false, + }); + assert.deepEqual( + catalog.profiles.map((profile) => profile.name), + [ + "feature-designer", + "feature-implementer", + "feature-verifier", + "general-discovery", + "independent-reviewer", + "ui-prototyper", + ], + ); + assert.equal(catalog.diagnostics.length, 0); + assert.ok( + catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)), + ); +}); + +test("trusted project profiles override bundled profiles while first duplicate wins", async () => { + await withTempDir(async (directory) => { + const bundled = path.join(directory, "bundled"); + const project = path.join(directory, "project"); + await writeProfile(bundled, "10-shared.md", { + name: "shared", + description: "bundled first", + }); + await writeProfile(bundled, "20-shared.md", { + name: "shared", + description: "bundled duplicate", + }); + await writeProfile(project, "10-shared.md", { + name: "shared", + description: "project override", + }); + await writeFile(path.join(project, "20-broken.md"), "not frontmatter"); + + const untrusted = await loadAgentProfiles({ + cwd: directory, + bundledRoot: bundled, + projectRoot: project, + projectTrusted: false, + }); + assert.equal(untrusted.profiles[0]?.description, "bundled first"); + assert.equal(untrusted.diagnostics.length, 1); + assert.doesNotMatch(untrusted.diagnostics[0]?.sourcePath ?? "", /project/); + + const trusted = await loadAgentProfiles({ + cwd: directory, + bundledRoot: bundled, + projectRoot: project, + projectTrusted: true, + }); + assert.equal(trusted.profiles[0]?.description, "project override"); + assert.equal(trusted.profiles[0]?.source, "project"); + assert.ok( + trusted.diagnostics.some((item) => + /duplicate profile/.test(item.message), + ), + ); + assert.ok( + trusted.diagnostics.some((item) => + /missing YAML frontmatter/.test(item.message), + ), + ); + }); +}); + +test("malformed profiles are isolated and unknown fields produce warnings", async () => { + await withTempDir(async (directory) => { + const bundled = path.join(directory, "bundled"); + await writeProfile(bundled, "good.md", { + name: "good-profile", + description: "Coordinates https://example.test: path", + roles: "\n - discover", + extra: + "unknownField: ignored\nmetadata:\n owner: team\nlabels:\n - internal\n", + selection: + " stages:\n - discover\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n unknownSelection:\n owner: team\n", + }); + await writeFile( + path.join(bundled, "quoted.md"), + String.raw`--- +name: quoted-profile +description: "Path C:\\" # comment +roles: [discover] +selection: + stages: [discover] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: ["path\\", dashboard] # comment +--- + +Quoted values remain valid. +`, + ); + await writeFile( + path.join(bundled, "single.md"), + String.raw`--- +name: single-profile +description: 'Path C:\' # comment +roles: [discover] +selection: + stages: [discover] + anyTags: [] + allTags: [] + pathGlobs: [] + keywords: ['path\', dashboard] # comment +--- + +Single-quoted values keep literal backslashes. +`, + ); + await writeProfile(bundled, "bad-provider.md", { + name: "bad-provider", + extra: "model: provider/model\nprovider: provider\n", + }); + await writeProfile(bundled, "bad-name.md", { name: "Bad Name" }); + + const catalog = await loadAgentProfiles({ + cwd: directory, + bundledRoot: bundled, + projectTrusted: false, + }); + assert.deepEqual( + catalog.profiles.map((profile) => profile.name), + ["good-profile", "quoted-profile", "single-profile"], + ); + assert.equal( + catalog.profiles[0]?.description, + "Coordinates https://example.test: path", + ); + assert.deepEqual(catalog.profiles[1]?.selection.keywords, [ + "path\\", + "dashboard", + ]); + assert.deepEqual(catalog.profiles[2]?.selection.keywords, [ + "path\\", + "dashboard", + ]); + assert.ok( + catalog.diagnostics.some( + (item) => + item.severity === "warning" && /unknown profile/.test(item.message), + ), + ); + assert.ok( + catalog.diagnostics.some((item) => + /unknown selection/.test(item.message), + ), + ); + assert.ok( + catalog.diagnostics.some((item) => + /provider requires/.test(item.message), + ), + ); + assert.ok( + catalog.diagnostics.some((item) => /lowercase kebab/.test(item.message)), + ); + }); +}); + +test("a symlinked profile root is rejected", async () => { + await withTempDir(async (directory) => { + const actual = path.join(directory, "actual"); + const linked = path.join(directory, "linked"); + await writeProfile(actual, "agent.md", { name: "external-root" }); + await symlink(actual, linked); + await assert.rejects( + loadAgentProfiles({ + cwd: directory, + bundledRoot: linked, + projectTrusted: false, + }), + /profile root must not be a symlink/, + ); + }); +}); + +test("a symlinked project profile parent is rejected", async () => { + await withTempDir(async (directory) => { + const repository = path.join(directory, "repository"); + const bundled = path.join(directory, "bundled"); + const externalPi = path.join(directory, "external-pi"); + await mkdir(repository, { recursive: true }); + await writeProfile(bundled, "agent.md", { name: "bundled-agent" }); + await writeProfile(path.join(externalPi, "agents"), "agent.md", { + name: "external-project", + }); + await symlink(externalPi, path.join(repository, ".pi")); + + await assert.rejects( + loadAgentProfiles({ + cwd: repository, + bundledRoot: bundled, + projectTrusted: true, + }), + /project profile ancestry must not be a symlink/, + ); + }); +}); + +test("profile files and symlinks must resolve inside their source root", async () => { + await withTempDir(async (directory) => { + const bundled = path.join(directory, "bundled"); + const outside = path.join(directory, "outside.md"); + await mkdir(bundled, { recursive: true }); + await writeFile( + path.join(bundled, "internal.profile"), + profileDocument({ name: "internal-link" }), + ); + await writeFile(outside, profileDocument({ name: "outside-link" })); + await symlink("internal.profile", path.join(bundled, "internal.md")); + await symlink(outside, path.join(bundled, "outside.md")); + await symlink("missing.profile", path.join(bundled, "broken.md")); + + const catalog = await loadAgentProfiles({ + cwd: directory, + bundledRoot: bundled, + projectTrusted: false, + }); + assert.deepEqual( + catalog.profiles.map((profile) => profile.name), + ["internal-link"], + ); + assert.ok(catalog.profiles[0]?.resolvedPath.endsWith("internal.profile")); + assert.ok( + catalog.diagnostics.some((item) => + /escapes the profile root/.test(item.message), + ), + ); + assert.ok( + catalog.diagnostics.some((item) => item.sourcePath.endsWith("broken.md")), + ); + }); +}); + +test("the nearest trusted ancestor project profile root is selected", async () => { + await withTempDir(async (directory) => { + const bundled = path.join(directory, "bundled"); + const project = path.join(directory, ".pi", "agents"); + const nestedCwd = path.join(directory, "packages", "app"); + await mkdir(nestedCwd, { recursive: true }); + await writeProfile(bundled, "agent.md", { + name: "shared-agent", + description: "bundled", + }); + await writeProfile(project, "agent.md", { + name: "shared-agent", + description: "nearest project", + }); + + const trusted = await loadAgentProfiles({ + cwd: nestedCwd, + bundledRoot: bundled, + projectTrusted: true, + }); + assert.equal(trusted.profiles[0]?.description, "nearest project"); + + const untrusted = await loadAgentProfiles({ + cwd: nestedCwd, + bundledRoot: bundled, + projectTrusted: false, + }); + assert.equal(untrusted.profiles[0]?.description, "bundled"); + }); +}); + +test("active profile contents are pinned by digest", async () => { + await withTempDir(async (directory) => { + const bundled = path.join(directory, "bundled"); + await writeProfile(bundled, "agent.md", { + name: "pinned-agent", + body: "Original instructions.", + }); + const first = await loadAgentProfiles({ + cwd: directory, + bundledRoot: bundled, + projectTrusted: false, + }); + await writeProfile(bundled, "agent.md", { + name: "pinned-agent", + body: "Changed instructions.", + }); + const second = await loadAgentProfiles({ + cwd: directory, + bundledRoot: bundled, + projectTrusted: false, + }); + assert.equal(first.profiles[0]?.instructions, "Original instructions."); + assert.notEqual(first.profiles[0]?.digest, second.profiles[0]?.digest); + }); +}); + +function agent( + name: string, + selection: AgentProfile["selection"], +): AgentProfile { + return { + name, + description: name, + roles: ["discover"], + selection, + instructions: "Discover.", + source: "bundled", + sourcePath: name, + resolvedPath: name, + digest: name.padEnd(64, "0"), + }; +} + +const baseFacts: Omit = { + taskKind: "feature", + requestTerms: ["dashboard"], + candidatePaths: ["src/routes/dashboard/page.tsx"], + languages: ["typescript"], + tags: ["frontend", "ui"], + riskTags: [], +}; + +test("eligibility records exclusion reasons and selects the most specific discovery profile", () => { + const fallback = agent("general", { + stages: ["discover"], + anyTags: [], + allTags: [], + pathGlobs: [], + keywords: [], + }); + const dashboard = agent("dashboard", { + stages: ["discover"], + anyTags: ["ui"], + allTags: ["frontend"], + pathGlobs: ["src/**/dashboard/*.tsx"], + keywords: ["dashboard"], + }); + const backend = agent("backend", { + stages: ["discover"], + anyTags: ["backend"], + allTags: [], + pathGlobs: [], + keywords: [], + }); + + const result = selectInitialDiscoveryProfile( + [fallback, dashboard, backend], + baseFacts, + ); + assert.equal(result.selected.name, "dashboard"); + const excluded = result.evaluations.find( + (item) => item.profile.name === "backend", + ); + assert.equal(excluded?.eligible, false); + assert.deepEqual(excluded?.reasons, ["no optional tag matched"]); + + const preDiscovery = eligibleProfiles( + [fallback, dashboard], + { + ...baseFacts, + stage: "discover", + requestTerms: [], + candidatePaths: [], + tags: [], + }, + "discover", + ); + assert.equal( + preDiscovery.find((item) => item.eligible)?.profile.name, + "general", + ); +}); + +test("eligibility enforces every criterion and uses a code-point name tie-breaker", () => { + const emptySelection: AgentProfile["selection"] = { + stages: ["discover"], + anyTags: [], + allTags: [], + pathGlobs: [], + keywords: [], + }; + const wrongRole = { + ...agent("wrong-role", emptySelection), + roles: ["design"] as AgentProfile["roles"], + }; + const wrongStage = agent("wrong-stage", { + ...emptySelection, + stages: ["design"], + }); + const missingAllTag = agent("missing-all-tag", { + ...emptySelection, + allTags: ["backend"], + }); + const missingAnyTag = agent("missing-any-tag", { + ...emptySelection, + anyTags: ["backend"], + }); + const missingPath = agent("missing-path", { + ...emptySelection, + pathGlobs: ["server/**/*.ts"], + }); + const missingKeyword = agent("missing-keyword", { + ...emptySelection, + keywords: ["api"], + }); + + const evaluations = eligibleProfiles( + [ + wrongRole, + wrongStage, + missingAllTag, + missingAnyTag, + missingPath, + missingKeyword, + ], + { ...baseFacts, stage: "discover" }, + "discover", + ); + const reasons = new Map( + evaluations.map((evaluation) => [ + evaluation.profile.name, + evaluation.reasons, + ]), + ); + assert.deepEqual(reasons.get("wrong-role"), [ + "does not support role discover", + ]); + assert.deepEqual(reasons.get("wrong-stage"), [ + "does not support stage discover", + ]); + assert.deepEqual(reasons.get("missing-all-tag"), [ + "missing required tags: backend", + ]); + assert.deepEqual(reasons.get("missing-any-tag"), ["no optional tag matched"]); + assert.deepEqual(reasons.get("missing-path"), ["no candidate path matched"]); + assert.deepEqual(reasons.get("missing-keyword"), [ + "no request keyword matched", + ]); + + const tie = eligibleProfiles( + [agent("zeta", emptySelection), agent("alpha", emptySelection)], + { ...baseFacts, stage: "discover" }, + "discover", + ); + assert.deepEqual( + tie + .filter((evaluation) => evaluation.eligible) + .map((evaluation) => evaluation.profile.name), + ["alpha", "zeta"], + ); +}); diff --git a/extensions/pi-workflow/profiles.ts b/extensions/pi-workflow/profiles.ts new file mode 100644 index 0000000..02879d6 --- /dev/null +++ b/extensions/pi-workflow/profiles.ts @@ -0,0 +1,663 @@ +import { createHash } from "node:crypto"; +import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; +import * as path from "node:path"; +import { + PROFILE_ROLES, + REASONING_EFFORTS, + type AgentProfile, + type AssignmentStageKind, + type EligibilityResult, + type ProfileCatalog, + type ProfileDiagnostic, + type ProfileRole, + type ProfileSelection, + type WorkflowFacts, +} from "./domain.ts"; + +const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TOOL_NAME = /^[a-zA-Z0-9_-]+$/; +const TOP_LEVEL_FIELDS = new Set([ + "name", + "description", + "roles", + "model", + "provider", + "effort", + "tools", + "selection", +]); +const SELECTION_FIELDS = new Set([ + "stages", + "anyTags", + "allTags", + "pathGlobs", + "keywords", +]); + +interface ParsedFrontmatter { + values: Record; + body: string; +} + +interface LoadSourceResult { + profiles: AgentProfile[]; + diagnostics: ProfileDiagnostic[]; +} + +export interface LoadAgentProfilesOptions { + cwd: string; + projectTrusted: boolean; + bundledRoot?: string; + projectRoot?: string; +} + +function reason(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function compareText(left: string, right: string) { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function isEscaped(value: string, index: number) { + let backslashes = 0; + for ( + let cursor = index - 1; + cursor >= 0 && value[cursor] === "\\"; + cursor-- + ) { + backslashes++; + } + return backslashes % 2 === 1; +} + +function stripComment(value: string) { + let quote: "'" | '"' | undefined; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (quote) { + if (quote === "'" && character === "'" && value[index + 1] === "'") { + index++; + } else if ( + character === quote && + (quote === "'" || !isEscaped(value, index)) + ) { + quote = undefined; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + } else if ( + character === "#" && + (index === 0 || /\s/.test(value[index - 1] ?? "")) + ) { + return value.slice(0, index).trimEnd(); + } + } + return value; +} + +function splitFlowItems(value: string, label: string) { + const items: string[] = []; + let quote: "'" | '"' | undefined; + let start = 0; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (quote) { + if (quote === "'" && character === "'" && value[index + 1] === "'") { + index++; + } else if ( + character === quote && + (quote === "'" || !isEscaped(value, index)) + ) { + quote = undefined; + } + } else if (character === "'" || character === '"') { + quote = character; + } else if (character === ",") { + items.push(value.slice(start, index)); + start = index + 1; + } + } + if (quote) throw new Error(`${label}: unterminated quoted value`); + items.push(value.slice(start)); + return items; +} + +function scalar(value: string, label: string) { + const trimmed = stripComment(value).trim(); + if (trimmed.length === 0) throw new Error(`${label}: value cannot be empty`); + if (trimmed.startsWith('"')) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (typeof parsed !== "string") throw new Error("not a string"); + return parsed; + } catch (error) { + throw new Error(`${label}: invalid quoted string`, { cause: error }); + } + } + if (trimmed.startsWith("'")) { + if (!trimmed.endsWith("'") || trimmed.length < 2) { + throw new Error(`${label}: invalid quoted string`); + } + return trimmed.slice(1, -1).replaceAll("''", "'"); + } + return trimmed; +} + +function yamlValue(value: string, label: string): string | string[] { + const trimmed = stripComment(value).trim(); + if (!trimmed.startsWith("[")) return scalar(trimmed, label); + if (!trimmed.endsWith("]")) throw new Error(`${label}: invalid flow array`); + const inside = trimmed.slice(1, -1).trim(); + if (inside.length === 0) return []; + return splitFlowItems(inside, label).map((entry, index) => + scalar(entry, `${label}[${index}]`), + ); +} + +function parseFrontmatter( + contents: string, + sourcePath: string, +): ParsedFrontmatter { + const normalized = contents.replaceAll("\r\n", "\n"); + const lines = normalized.split("\n"); + if (lines[0] !== "---") throw new Error("missing YAML frontmatter"); + const closing = lines.indexOf("---", 1); + if (closing < 0) throw new Error("unterminated YAML frontmatter"); + + const values: Record = {}; + let section: Record | undefined; + let sectionName: string | undefined; + let skippedUnknownIndent: number | undefined; + let pendingList: + | { container: Record; key: string; indent: number } + | undefined; + + for (let lineIndex = 1; lineIndex < closing; lineIndex++) { + const raw = lines[lineIndex] ?? ""; + if (raw.trim().length === 0 || raw.trimStart().startsWith("#")) continue; + if (raw.includes("\t")) + throw new Error( + `frontmatter line ${lineIndex + 1}: tabs are not supported`, + ); + const indent = raw.length - raw.trimStart().length; + if (skippedUnknownIndent !== undefined) { + if (indent > skippedUnknownIndent) continue; + skippedUnknownIndent = undefined; + } + if (indent % 2 !== 0) { + throw new Error( + `frontmatter line ${lineIndex + 1}: indentation must use pairs of spaces`, + ); + } + const text = stripComment(raw.trim()); + if (text.length === 0) continue; + + if (text.startsWith("- ")) { + if (!pendingList || indent !== pendingList.indent + 2) { + throw new Error( + `frontmatter line ${lineIndex + 1}: unexpected list item`, + ); + } + const list = pendingList.container[pendingList.key]; + if (!Array.isArray(list)) + throw new Error(`${sourcePath}: invalid list state`); + list.push(scalar(text.slice(2), `frontmatter line ${lineIndex + 1}`)); + continue; + } + + pendingList = undefined; + const separator = text.indexOf(":"); + if (separator < 1) + throw new Error(`frontmatter line ${lineIndex + 1}: expected key: value`); + const key = text.slice(0, separator).trim(); + const rawValue = text.slice(separator + 1).trim(); + if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key)) { + throw new Error( + `frontmatter line ${lineIndex + 1}: invalid key ${JSON.stringify(key)}`, + ); + } + + let container: Record; + if (indent === 0) { + container = values; + section = undefined; + sectionName = undefined; + } else if (indent === 2 && section && sectionName) { + container = section; + } else { + throw new Error(`frontmatter line ${lineIndex + 1}: unsupported nesting`); + } + if (Object.hasOwn(container, key)) { + throw new Error( + `frontmatter line ${lineIndex + 1}: duplicate field ${key}`, + ); + } + + if (rawValue.length > 0) { + container[key] = yamlValue(rawValue, `frontmatter field ${key}`); + continue; + } + + const knownField = + indent === 0 ? TOP_LEVEL_FIELDS.has(key) : SELECTION_FIELDS.has(key); + if (!knownField) { + container[key] = ""; + skippedUnknownIndent = indent; + continue; + } + + if (indent === 0 && key === "selection") { + section = {}; + sectionName = key; + values[key] = section; + } else { + container[key] = []; + pendingList = { container, key, indent }; + } + } + + return { + values, + body: lines + .slice(closing + 1) + .join("\n") + .trim(), + }; +} + +function requiredString(value: unknown, field: string) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${field} must be a non-empty string`); + } + return value.trim(); +} + +function optionalString(value: unknown, field: string) { + return value === undefined ? undefined : requiredString(value, field); +} + +function stringList(value: unknown, field: string, allowCsv = false) { + const list = + allowCsv && typeof value === "string" + ? value.split(",").map((entry) => entry.trim()) + : value; + if (!Array.isArray(list) || list.some((entry) => typeof entry !== "string")) { + throw new Error( + `${field} must be an array of strings${allowCsv ? " or CSV string" : ""}`, + ); + } + const normalized = list.map((entry) => entry.trim()); + if (normalized.some((entry) => entry.length === 0)) { + throw new Error(`${field} cannot contain empty values`); + } + if (new Set(normalized).size !== normalized.length) { + throw new Error(`${field} cannot contain duplicate values`); + } + return normalized; +} + +function enumList( + value: unknown, + field: string, + allowed: readonly T[], +): T[] { + const entries = stringList(value, field); + const invalid = entries.filter((entry) => !allowed.includes(entry as T)); + if (invalid.length > 0) { + throw new Error( + `${field} contains unsupported values: ${invalid.join(", ")}`, + ); + } + return entries as T[]; +} + +function parseSelection(value: unknown) { + if (!isRecord(value)) throw new Error("selection must be an object"); + const warnings = Object.keys(value) + .filter((key) => !SELECTION_FIELDS.has(key)) + .map((key) => `unknown selection field ${key}`); + const stages = enumList(value.stages, "selection.stages", PROFILE_ROLES); + if (stages.length === 0) + throw new Error("selection.stages must not be empty"); + const normalize = (entries: string[]) => + entries.map((entry) => entry.toLowerCase()); + const selection: ProfileSelection = { + stages, + anyTags: normalize(stringList(value.anyTags ?? [], "selection.anyTags")), + allTags: normalize(stringList(value.allTags ?? [], "selection.allTags")), + pathGlobs: stringList(value.pathGlobs ?? [], "selection.pathGlobs").map( + (entry) => entry.replaceAll("\\", "/"), + ), + keywords: normalize(stringList(value.keywords ?? [], "selection.keywords")), + }; + return { selection, warnings }; +} + +function buildProfile( + contents: string, + sourcePath: string, + resolvedPath: string, + source: AgentProfile["source"], +) { + const parsed = parseFrontmatter(contents, sourcePath); + const warnings = Object.keys(parsed.values) + .filter((key) => !TOP_LEVEL_FIELDS.has(key)) + .map((key) => `unknown profile field ${key}`); + + const name = requiredString(parsed.values.name, "name"); + if (!PROFILE_NAME.test(name)) { + throw new Error("name must be lowercase kebab case"); + } + const description = requiredString(parsed.values.description, "description"); + const roles = enumList( + parsed.values.roles, + "roles", + PROFILE_ROLES, + ) as ProfileRole[]; + if (roles.length === 0) throw new Error("roles must not be empty"); + const model = optionalString(parsed.values.model, "model"); + const provider = optionalString(parsed.values.provider, "provider"); + if (provider && (!model || model.includes("/"))) { + throw new Error("provider requires a bare model field"); + } + const effort = optionalString(parsed.values.effort, "effort"); + if (effort && !REASONING_EFFORTS.includes(effort as never)) { + throw new Error(`effort must be one of ${REASONING_EFFORTS.join(", ")}`); + } + const tools = + parsed.values.tools === undefined + ? undefined + : stringList(parsed.values.tools, "tools", true); + if (tools?.some((tool) => !TOOL_NAME.test(tool))) { + throw new Error("tools contains an invalid tool name"); + } + if (parsed.body.length === 0) + throw new Error("agent instructions must not be empty"); + const { selection, warnings: selectionWarnings } = parseSelection( + parsed.values.selection, + ); + + const profile: AgentProfile = { + name, + description, + roles, + ...(model ? { model } : {}), + ...(provider ? { provider } : {}), + ...(effort ? { effort: effort as AgentProfile["effort"] } : {}), + ...(tools ? { tools } : {}), + selection, + instructions: parsed.body, + source, + sourcePath, + resolvedPath, + digest: createHash("sha256").update(contents).digest("hex"), + }; + return { profile, warnings: [...warnings, ...selectionWarnings] }; +} + +function isInside(parent: string, child: string) { + const relative = path.relative(parent, child); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +async function loadSource( + root: string, + source: AgentProfile["source"], + required: boolean, +): Promise { + const diagnostics: ProfileDiagnostic[] = []; + let resolvedRoot: string; + try { + const rootInfo = await lstat(root); + if (rootInfo.isSymbolicLink()) { + throw new Error("profile root must not be a symlink"); + } + if (!rootInfo.isDirectory()) { + throw new Error("profile root must be a directory"); + } + resolvedRoot = await realpath(root); + } catch (error) { + if (!required && (error as NodeJS.ErrnoException).code === "ENOENT") { + return { profiles: [], diagnostics }; + } + throw new Error( + `${root}: unable to resolve profile root: ${reason(error)}`, + { + cause: error, + }, + ); + } + + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + throw new Error(`${root}: unable to read profile root`, { cause: error }); + } + + const profiles: AgentProfile[] = []; + const names = new Set(); + for (const entry of entries.sort((left, right) => + compareText(left.name, right.name), + )) { + if ( + !entry.name.endsWith(".md") || + (!entry.isFile() && !entry.isSymbolicLink()) + ) + continue; + const sourcePath = path.join(root, entry.name); + try { + const resolvedPath = await realpath(sourcePath); + if (!isInside(resolvedRoot, resolvedPath)) { + throw new Error("resolved path escapes the profile root"); + } + if (!(await stat(resolvedPath)).isFile()) { + throw new Error("profile is not a regular file"); + } + const contents = await readFile(resolvedPath, "utf8"); + const { profile, warnings } = buildProfile( + contents, + sourcePath, + resolvedPath, + source, + ); + for (const message of warnings) { + diagnostics.push({ sourcePath, severity: "warning", message }); + } + if (names.has(profile.name)) { + diagnostics.push({ + sourcePath, + severity: "error", + message: `duplicate profile name ${profile.name}; first lexical file wins`, + }); + continue; + } + names.add(profile.name); + profiles.push(profile); + } catch (error) { + diagnostics.push({ + sourcePath, + severity: "error", + message: reason(error), + }); + } + } + return { profiles, diagnostics }; +} + +async function assertProjectProfileAncestry(root: string) { + const parent = path.dirname(root); + const parentInfo = await lstat(parent); + if (parentInfo.isSymbolicLink()) { + throw new Error( + `${parent}: project profile ancestry must not be a symlink`, + ); + } + if (!parentInfo.isDirectory()) { + throw new Error(`${parent}: project profile parent must be a directory`); + } +} + +async function findNearestProjectRoot(cwd: string) { + let current = path.resolve(cwd); + while (true) { + const candidate = path.join(current, ".pi", "agents"); + try { + await lstat(candidate); + return candidate; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new Error(`${candidate}: unable to inspect profile root`, { + cause: error, + }); + } + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +export async function loadAgentProfiles( + options: LoadAgentProfilesOptions, +): Promise { + const bundledRoot = + options.bundledRoot ?? path.join(import.meta.dirname, "agents"); + const bundled = await loadSource(bundledRoot, "bundled", true); + const projectRoot = options.projectTrusted + ? (options.projectRoot ?? (await findNearestProjectRoot(options.cwd))) + : undefined; + if (projectRoot) await assertProjectProfileAncestry(projectRoot); + const project = projectRoot + ? await loadSource(projectRoot, "project", false) + : { profiles: [], diagnostics: [] }; + + const byName = new Map( + bundled.profiles.map((profile) => [profile.name, profile]), + ); + for (const profile of project.profiles) byName.set(profile.name, profile); + return { + profiles: [...byName.values()].sort((left, right) => + compareText(left.name, right.name), + ), + diagnostics: [...bundled.diagnostics, ...project.diagnostics], + }; +} + +function globPattern(pattern: string) { + let expression = "^"; + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index] ?? ""; + if (character === "*" && pattern[index + 1] === "*") { + if (pattern[index + 2] === "/") { + expression += "(?:.*/)?"; + index += 2; + } else { + expression += ".*"; + index++; + } + } else if (character === "*") { + expression += "[^/]*"; + } else if (character === "?") { + expression += "[^/]"; + } else { + expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } + } + return new RegExp(`${expression}$`); +} + +export function evaluateProfileEligibility( + profile: AgentProfile, + facts: WorkflowFacts, + role: ProfileRole, +): EligibilityResult { + const reasons: string[] = []; + const tags = new Set(facts.tags.map((tag) => tag.toLowerCase())); + const terms = new Set(facts.requestTerms.map((term) => term.toLowerCase())); + const paths = facts.candidatePaths.map((candidate) => + candidate.replaceAll("\\", "/"), + ); + + if (!profile.roles.includes(role)) + reasons.push(`does not support role ${role}`); + if (!profile.selection.stages.includes(facts.stage)) { + reasons.push(`does not support stage ${facts.stage}`); + } + const missingTags = profile.selection.allTags.filter((tag) => !tags.has(tag)); + if (missingTags.length > 0) + reasons.push(`missing required tags: ${missingTags.join(", ")}`); + if ( + profile.selection.anyTags.length > 0 && + !profile.selection.anyTags.some((tag) => tags.has(tag)) + ) { + reasons.push("no optional tag matched"); + } + if ( + profile.selection.pathGlobs.length > 0 && + !profile.selection.pathGlobs.some((pattern) => { + const matcher = globPattern(pattern); + return paths.some((candidate) => matcher.test(candidate)); + }) + ) { + reasons.push("no candidate path matched"); + } + if ( + profile.selection.keywords.length > 0 && + !profile.selection.keywords.some((keyword) => terms.has(keyword)) + ) { + reasons.push("no request keyword matched"); + } + + return { + profile, + eligible: reasons.length === 0, + reasons, + specificity: + profile.selection.allTags.length + + profile.selection.anyTags.length + + profile.selection.pathGlobs.length + + profile.selection.keywords.length, + }; +} + +export function eligibleProfiles( + profiles: AgentProfile[], + facts: WorkflowFacts, + role: ProfileRole, +) { + return profiles + .map((profile) => evaluateProfileEligibility(profile, facts, role)) + .sort( + (left, right) => + Number(right.eligible) - Number(left.eligible) || + right.specificity - left.specificity || + compareText(left.profile.name, right.profile.name), + ); +} + +export function selectInitialDiscoveryProfile( + profiles: AgentProfile[], + facts: Omit, +) { + const evaluations = eligibleProfiles( + profiles, + { ...facts, stage: "discover" as AssignmentStageKind }, + "discover", + ); + const selected = evaluations.find((evaluation) => evaluation.eligible); + if (!selected) + throw new Error("No eligible discovery Agent Profile is available"); + return { selected: selected.profile, evaluations }; +} diff --git a/extensions/pi-workflow/sandbox-child.cjs b/extensions/pi-workflow/sandbox-child.cjs new file mode 100644 index 0000000..42d8571 --- /dev/null +++ b/extensions/pi-workflow/sandbox-child.cjs @@ -0,0 +1,280 @@ +"use strict"; + +// This file is launched by sandbox.ts in Node permission mode. It deliberately +// has no filesystem/network/child-process permissions and receives workflow +// source only over a validated IPC channel. +const vm = require("node:vm"); +const sendIpc = + typeof process.send === "function" ? process.send.bind(process) : undefined; +// If a future V8 escape exposes `process`, remove the convenient bridges to +// builtins, native bindings, parent signalling, and addons before any workflow +// source is compiled. The parent still enforces the authenticated IPC protocol. +for (const capability of [ + "getBuiltinModule", + "binding", + "_linkedBinding", + "dlopen", + "kill", + "abort", + "send", +]) { + try { + Object.defineProperty(process, capability, { + value: undefined, + writable: false, + configurable: false, + }); + } catch { + // The VM boundary and permission mode remain mandatory controls. + } +} + +const BOOTSTRAP = String.raw` +(function bootstrapScriptApi() { + "use strict"; + const callHost = globalThis.__hostBridge; + delete globalThis.__hostBridge; + let nextRequestId = 0; + const unconsumed = new Set(); + const inFlight = new Set(); + + function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const key of Object.keys(value)) deepFreeze(value[key]); + return value; + } + + function requestAssignment(roleValue, promptValue) { + const id = ++nextRequestId; + unconsumed.add(id); + let started; + const begin = () => { + unconsumed.delete(id); + if (!started) { + let payload; + try { + payload = JSON.stringify({ + id, + role: typeof roleValue === "string" ? roleValue : String(roleValue ?? ""), + prompt: typeof promptValue === "string" ? promptValue : String(promptValue ?? ""), + }); + } catch (error) { + started = Promise.reject(new Error("assign() arguments must be serializable: " + error.message)); + return started; + } + inFlight.add(id); + started = callHost("assign", payload) + .then((json) => { + const result = JSON.parse(json); + if (result && result.ok === false) { + throw new Error(result.error || "Script Assignment failed"); + } + return result; + }) + .finally(() => inFlight.delete(id)); + } + return started; + }; + return Object.freeze({ + then(resolve, reject) { + return begin().then(resolve, reject); + }, + catch(reject) { + return begin().catch(reject); + }, + finally(callback) { + return begin().finally(callback); + }, + get [Symbol.toStringTag]() { + return "Promise"; + }, + }); + } + + async function mapLimited(items, concurrency, invoke) { + const results = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (true) { + const index = next++; + if (index >= items.length) return; + results[index] = await invoke(items[index]); + } + }); + await Promise.all(workers); + return results; + } + + async function parallel(items, options = {}) { + if (!Array.isArray(items)) throw new Error("parallel() expects an array of zero-argument assignment thunks"); + const requested = options && typeof options.concurrency === "number" + ? Math.floor(options.concurrency) + : 4; + if (!Number.isFinite(requested) || requested < 1) { + throw new Error("parallel(): concurrency must be a positive integer"); + } + const concurrency = Math.min(4, requested); + return mapLimited(items, concurrency, (item) => { + if (typeof item !== "function") { + throw new Error("parallel() items must be zero-argument functions"); + } + return item(); + }); + } + + function stage(title) { + callHost("stage", JSON.stringify({ title: String(title) })); + } + + const argsEnvelope = JSON.parse(globalThis.__argsJson); + const args = argsEnvelope.defined ? deepFreeze(argsEnvelope.value) : undefined; + delete globalThis.__argsJson; + const stringify = JSON.stringify; + function serializeResult(value) { + const seen = new WeakSet(); + return stringify(value === undefined ? null : value, (_key, item) => { + if (typeof item === "bigint") return item.toString() + "n"; + if (item && typeof item === "object") { + if (seen.has(item)) return "[circular]"; + seen.add(item); + } + return item; + }); + } + Object.defineProperties(globalThis, { + assign: { value: requestAssignment, writable: false, configurable: false }, + parallel: { value: parallel, writable: false, configurable: false }, + stage: { value: stage, writable: false, configurable: false }, + args: { value: args, writable: false, configurable: false }, + __workflowCheck: { + value: Object.freeze(() => ({ + unconsumed: unconsumed.size, + inFlight: inFlight.size, + })), + writable: false, + configurable: false, + }, + __workflowSerialize: { + value: Object.freeze(serializeResult), + writable: false, + configurable: false, + }, + }); +})(); +`; + +let initialized = false; +let token; +const pendingAssignments = new Map(); + +function send(message) { + sendIpc?.({ token, ...message }); +} + +function fail(error) { + const message = error instanceof Error ? error.message : String(error); + send({ kind: "error", error: message.slice(0, 16 * 1024) }); +} + +process.on("message", (message) => { + if (!message || typeof message !== "object") return; + if (!initialized) { + if ( + message.kind !== "init" || + typeof message.token !== "string" || + typeof message.source !== "string" || + typeof message.argsJson !== "string" + ) { + process.exitCode = 1; + return; + } + initialized = true; + token = message.token; + run(message.source, message.argsJson); + return; + } + if (message.token !== token || message.kind !== "assignmentResult") return; + const pending = pendingAssignments.get(message.id); + if (!pending) return; + pendingAssignments.delete(message.id); + if (typeof message.resultJson === "string") + pending.resolve(message.resultJson); + else + pending.reject( + new Error( + typeof message.error === "string" ? message.error : "Agent IPC failed", + ), + ); +}); + +function run(source, argsJson) { + try { + const sandbox = Object.create(null); + sandbox.__argsJson = argsJson; + sandbox.__hostBridge = (kind, payloadJson) => { + if (kind === "stage") { + send({ kind: "stage", payloadJson }); + return undefined; + } + if (kind !== "assign") + return Promise.reject(new Error("Unknown workflow operation")); + let id; + try { + id = JSON.parse(payloadJson).id; + } catch { + return Promise.reject(new Error("Invalid assign request")); + } + return new Promise((resolve, reject) => { + pendingAssignments.set(id, { resolve, reject }); + send({ kind: "assign", payloadJson }); + }); + }; + + const context = vm.createContext(sandbox, { + name: "pi-workflow", + codeGeneration: { strings: false, wasm: false }, + }); + new vm.Script(BOOTSTRAP, { + filename: "script-bootstrap.js", + }).runInContext(context, { timeout: 1000 }); + const workflow = vm.compileFunction( + `"use strict";\nreturn (async function workflow() {\n${source}\n})();`, + ["assign", "parallel", "stage", "args"], + { filename: "pi-workflow-script.js", parsingContext: context }, + ); + context.__workflowBody = workflow; + const invoke = ` + (() => { + const workflowBody = globalThis.__workflowBody; + delete globalThis.__workflowBody; + globalThis.__workflowPromise = Promise.resolve( + workflowBody(assign, parallel, stage, args), + ).then(async (value) => { + await Promise.resolve(); + const pending = __workflowCheck(); + if (pending.unconsumed > 0) { + throw new Error("Workflow created " + pending.unconsumed + " unawaited assign() call(s)"); + } + if (pending.inFlight > 0) { + throw new Error("Workflow returned before " + pending.inFlight + " assign call(s) settled"); + } + return __workflowSerialize(value); + }); + })(); + `; + new vm.Script(invoke, { filename: "script-invoke.js" }).runInContext( + context, + { timeout: 1000 }, + ); + Promise.resolve(context.__workflowPromise) + .then((resultJson) => { + if (typeof resultJson !== "string") + throw new Error("Workflow result was not serializable"); + send({ kind: "result", resultJson }); + }) + .catch(fail); + } catch (error) { + fail(error); + } +} diff --git a/extensions/pi-workflow/script-runtime.test.ts b/extensions/pi-workflow/script-runtime.test.ts new file mode 100644 index 0000000..8a32767 --- /dev/null +++ b/extensions/pi-workflow/script-runtime.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + parseScriptAssignmentPayload, + runScriptSandbox, + ScriptRequestBudget, + validateScriptIpcEnvelope, +} from "./script-runtime.ts"; + +function run( + source: string, + overrides: Partial[0]> = {}, +) { + const abort = new AbortController(); + return runScriptSandbox({ + source, + args: undefined, + cwd: process.cwd(), + signal: abort.signal, + onAssign: async (role, prompt) => ({ + ok: true, + output: `${role}:${prompt}`, + }), + onStage: () => {}, + ...overrides, + }); +} + +test("script sandbox exposes only stage, assign, parallel, and immutable args", async () => { + const stages: string[] = []; + const result = await run( + ` + stage("Audit"); + const replies = await parallel([ + () => assign("review", "one"), + () => assign("review", "two"), + ], { concurrency: 99 }); + return { + replies: replies.map((reply) => reply.output), + processType: typeof process, + requireType: typeof require, + fetchType: typeof fetch, + nestedArgsFrozen: Object.isFrozen(args.nested), + }; + `, + { + args: { nested: { value: "immutable" } }, + onStage: (title) => stages.push(title), + }, + ); + assert.deepEqual(result, { + replies: ["review:one", "review:two"], + processType: "undefined", + requireType: "undefined", + fetchType: "undefined", + nestedArgsFrozen: true, + }); + assert.deepEqual(stages, ["Audit"]); +}); + +test("script sandbox rejects unawaited assignments and synchronous loops", async () => { + let calls = 0; + await assert.rejects( + run(`assign("review", "orphan"); return "done";`, { + onAssign: async () => { + calls++; + return { ok: true, output: "unexpected" }; + }, + }), + /unawaited assign/, + ); + assert.equal(calls, 0); + await assert.rejects(run(`while (true) {}`), /timed out/); +}); + +test("script cancellation aborts active assignments and terminates execution", async () => { + const controller = new AbortController(); + let startedResolve: (() => void) | undefined; + const started = new Promise((resolve) => { + startedResolve = resolve; + }); + let requestAborted = false; + const pending = run(`return await assign("review", "pending");`, { + signal: controller.signal, + onAssign: async (_role, _prompt, signal) => { + startedResolve?.(); + await new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + requestAborted = true; + resolve(); + }, + { once: true }, + ); + }); + return { ok: false, output: "", error: "Assignment was aborted" }; + }, + }); + await started; + controller.abort(new Error("cancel fixture")); + await assert.rejects(pending, /Script was aborted/); + assert.equal(requestAborted, true); +}); + +test("script IPC rejects unauthenticated, malformed, oversized, duplicate, and over-budget requests", () => { + assert.throws( + () => validateScriptIpcEnvelope({ token: "wrong", kind: "assign" }, "right"), + /invalid IPC message/, + ); + assert.throws(() => parseScriptAssignmentPayload("{"), /malformed assignment JSON/); + assert.throws( + () => parseScriptAssignmentPayload("x".repeat(512 * 1024 + 1)), + /oversized assignment request/, + ); + assert.throws( + () => parseScriptAssignmentPayload(JSON.stringify({ id: 1, role: "", prompt: "x" })), + /invalid assignment request/, + ); + const duplicate = new ScriptRequestBudget(); + duplicate.accept(1); + assert.throws(() => duplicate.accept(1), /duplicate assignment request/); + const budget = new ScriptRequestBudget(); + for (let id = 1; id <= 32; id++) budget.accept(id); + assert.throws(() => budget.accept(33), /request budget/); +}); + +test("script stage updates and total runtime are bounded", async () => { + await assert.rejects( + run(`for (let index = 0; index < 65; index++) stage("flood-" + index);`), + /stage update budget/, + ); + await assert.rejects( + run(`while (true) { await Promise.resolve(); }`, { runtimeTimeoutMs: 20 }), + /wall-clock time limit/, + ); +}); + +test("script source and args are bounded", async () => { + await assert.rejects(run("x".repeat(512 * 1024 + 1)), /source exceeds/); + await assert.rejects( + run("return null", { args: "x".repeat(256 * 1024 + 1) }), + /args exceed/, + ); +}); diff --git a/extensions/pi-workflow/script-runtime.ts b/extensions/pi-workflow/script-runtime.ts new file mode 100644 index 0000000..24e68e7 --- /dev/null +++ b/extensions/pi-workflow/script-runtime.ts @@ -0,0 +1,339 @@ +import { randomBytes } from "node:crypto"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { safeStringify, toSerializable } from "../_workflows/serialization.ts"; + +const MAX_SOURCE_BYTES = 512 * 1024; +const MAX_ARGS_BYTES = 256 * 1024; +const MAX_RESULT_BYTES = 1024 * 1024; +const MAX_ASSIGNMENT_MESSAGE_BYTES = 512 * 1024; +const MAX_ASSIGNMENT_REQUESTS = 32; +const MAX_STAGE_UPDATES = 64; +const DEFAULT_SCRIPT_RUNTIME_MS = 10 * 60 * 1000; + +export interface ScriptAssignmentResult { + ok: boolean; + output: string; + structured?: unknown; + error?: string; +} + +export interface RunScriptSandboxOptions { + source: string; + args: unknown; + cwd: string; + signal: AbortSignal; + runtimeTimeoutMs?: number; + onAssign: ( + role: string, + prompt: string, + signal: AbortSignal, + requestId: number, + ) => Promise; + onStage: (title: string) => void; +} + +function byteLength(value: string) { + return Buffer.byteLength(value, "utf8"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorText(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function terminateChild(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + const force = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + }, 1_000); + force.unref?.(); +} + +export function validateScriptIpcEnvelope(raw: unknown, token: string) { + if (!isRecord(raw) || raw.token !== token || typeof raw.kind !== "string") { + throw new Error("Script sandbox sent an invalid IPC message"); + } + return raw; +} + +export function parseScriptAssignmentPayload(payloadJson: unknown) { + if ( + typeof payloadJson !== "string" || + byteLength(payloadJson) > MAX_ASSIGNMENT_MESSAGE_BYTES + ) { + throw new Error("Script sandbox sent an oversized assignment request"); + } + let payload: unknown; + try { + payload = JSON.parse(payloadJson); + } catch { + throw new Error("Script sandbox sent malformed assignment JSON"); + } + if ( + !isRecord(payload) || + !Number.isSafeInteger(payload.id) || + typeof payload.id !== "number" || + payload.id < 1 || + typeof payload.role !== "string" || + payload.role.trim().length === 0 || + payload.role.length > 80 || + typeof payload.prompt !== "string" || + payload.prompt.length > 100_000 + ) { + throw new Error("Script sandbox sent an invalid assignment request"); + } + return { + id: payload.id, + role: payload.role.trim(), + prompt: payload.prompt, + }; +} + +export class ScriptRequestBudget { + private readonly ids = new Set(); + private count = 0; + + accept(id: number) { + if (this.ids.has(id)) { + throw new Error("Script sandbox sent a duplicate assignment request"); + } + if (++this.count > MAX_ASSIGNMENT_REQUESTS) { + throw new Error("Script sandbox exceeded its assignment request budget"); + } + this.ids.add(id); + } +} + +/** + * Execute orchestration code in a separate, permission-restricted Node process. + * The child can only invoke the narrow agent/phase IPC protocol and is always + * terminated on completion, cancellation, or protocol failure. The workflow + * itself and its assignment requests have no wall-clock deadline. Active requests + * are aborted only when the workflow is cancelled or the sandbox is cleaned up. + */ +export function runScriptSandbox(options: RunScriptSandboxOptions) { + if (!process.allowedNodeEnvironmentFlags.has("--permission")) { + return Promise.reject( + new Error("This Node runtime cannot enforce script child permissions"), + ); + } + if (byteLength(options.source) > MAX_SOURCE_BYTES) { + return Promise.reject( + new Error(`Script source exceeds the ${MAX_SOURCE_BYTES} byte limit`), + ); + } + + try { + const exactArgsJson = JSON.stringify({ + defined: options.args !== undefined, + value: options.args, + }); + if (exactArgsJson && byteLength(exactArgsJson) > MAX_ARGS_BYTES) { + return Promise.reject(new Error("Script args exceed the IPC limit")); + } + } catch { + // The bounded serializer below handles cycles and hostile values. + } + + const argsJson = safeStringify( + { defined: options.args !== undefined, value: options.args }, + { maxBytes: MAX_ARGS_BYTES + 1, maxDepth: 16, maxNodes: 10_000 }, + ); + if (byteLength(argsJson) > MAX_ARGS_BYTES) { + return Promise.reject(new Error("Script args exceed the IPC limit")); + } + + return new Promise((resolve, reject) => { + const workerPath = fileURLToPath( + new URL("./sandbox-child.cjs", import.meta.url), + ); + const child = spawn( + process.execPath, + [ + "--permission", + `--allow-fs-read=${path.dirname(workerPath)}`, + "--max-old-space-size=128", + "--stack-size=2048", + workerPath, + ], + { + cwd: options.cwd, + env: { + PATH: process.env.PATH ?? "", + NODE_NO_WARNINGS: "1", + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }, + ); + const token = randomBytes(24).toString("hex"); + const requestBudget = new ScriptRequestBudget(); + const activeAssignmentRequests = new Map(); + let stageCount = 0; + let finished = false; + const runtimeTimer = setTimeout( + () => finish(new Error("Script exceeded its wall-clock time limit")), + options.runtimeTimeoutMs ?? DEFAULT_SCRIPT_RUNTIME_MS, + ); + runtimeTimer.unref?.(); + + const cleanup = () => { + for (const abortController of activeAssignmentRequests.values()) { + abortController.abort(new Error("Script stopped")); + } + activeAssignmentRequests.clear(); + clearTimeout(runtimeTimer); + options.signal.removeEventListener("abort", onAbort); + child.removeAllListeners("message"); + child.removeAllListeners("error"); + child.removeAllListeners("exit"); + terminateChild(child); + }; + const finish = (error?: Error, value?: unknown) => { + if (finished) return; + finished = true; + cleanup(); + if (error) reject(error); + else resolve(value); + }; + const onAbort = () => finish(new Error("Script was aborted")); + + options.signal.addEventListener("abort", onAbort, { once: true }); + if (options.signal.aborted) { + onAbort(); + return; + } + + child.on("error", (error) => finish(error)); + child.on("exit", (code, exitSignal) => { + if (!finished) { + finish( + new Error( + `Script sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`, + ), + ); + } + }); + child.on("message", (message: unknown) => { + let raw: Record; + try { + raw = validateScriptIpcEnvelope(message, token); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + return; + } + if (raw.kind === "stage") { + if (++stageCount > MAX_STAGE_UPDATES) { + finish(new Error("Script sandbox exceeded its stage update budget")); + return; + } + if ( + typeof raw.payloadJson !== "string" || + byteLength(raw.payloadJson) > 4096 + ) { + finish(new Error("Script sandbox sent an invalid stage update")); + return; + } + try { + const payload: unknown = JSON.parse(raw.payloadJson); + if (!isRecord(payload) || typeof payload.title !== "string") { + throw new Error("invalid title"); + } + options.onStage(payload.title.slice(0, 160)); + } catch { + finish(new Error("Script sandbox sent an invalid stage update")); + } + return; + } + if (raw.kind === "assign") { + let payload: ReturnType; + try { + payload = parseScriptAssignmentPayload(raw.payloadJson); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + return; + } + try { + requestBudget.accept(payload.id); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + return; + } + const id = payload.id; + const abortController = new AbortController(); + const sendResult = (result: ScriptAssignmentResult) => { + if (!activeAssignmentRequests.delete(id)) return; + if (finished || !child.connected) return; + const normalized = toSerializable(result, { + maxDepth: 16, + maxNodes: 10_000, + maxStringBytes: 128 * 1024, + }); + let resultJson = JSON.stringify(normalized); + if (byteLength(resultJson) > MAX_ASSIGNMENT_MESSAGE_BYTES) { + resultJson = JSON.stringify({ + ok: false, + output: "", + error: "Assignment result exceeded the script IPC output limit", + }); + } + child.send({ token, kind: "assignmentResult", id, resultJson }); + }; + activeAssignmentRequests.set(id, abortController); + void options + .onAssign( + payload.role.trim(), + payload.prompt, + abortController.signal, + payload.id, + ) + .then(sendResult) + .catch((error) => + sendResult({ ok: false, output: "", error: errorText(error) }), + ); + return; + } + if (raw.kind === "result") { + if ( + typeof raw.resultJson !== "string" || + byteLength(raw.resultJson) > MAX_RESULT_BYTES + ) { + finish(new Error("Script result exceeded the IPC limit")); + return; + } + try { + const normalized = toSerializable(JSON.parse(raw.resultJson)); + finish(undefined, JSON.parse(JSON.stringify(normalized))); + } catch (error) { + finish( + new Error(`Script returned invalid JSON: ${errorText(error)}`), + ); + } + return; + } + if (raw.kind === "error" && typeof raw.error === "string") { + finish(new Error(raw.error.slice(0, 16 * 1024))); + return; + } + finish(new Error("Script sandbox sent an unknown IPC message")); + }); + + child.send( + { + kind: "init", + token, + source: options.source, + argsJson, + }, + (error) => { + if (error) finish(error); + }, + ); + }); +} diff --git a/extensions/pi-workflow/store.test.ts b/extensions/pi-workflow/store.test.ts new file mode 100644 index 0000000..a51d161 --- /dev/null +++ b/extensions/pi-workflow/store.test.ts @@ -0,0 +1,512 @@ +import assert from "node:assert/strict"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts"; +import { + readVerifiedReportArtifact, + WorkflowStore, +} from "./store.ts"; + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-store-")); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function workflow(runId = "run-1"): WorkflowRun { + return { + runId, + revision: 0, + mode: "feature", + status: "running", + background: false, + request: "Build the feature", + featureName: "build-feature", + parentSessionIds: ["session-1"], + lease: { + processId: 100, + activeParentSessionId: "session-1", + heartbeatAt: 1, + }, + workspace: { + state: "creating", + sourceRoot: "/repository", + root: `/repository/.worktrees/${runId}`, + branch: "feature/build-feature", + baseHead: "abc123", + }, + effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG), + pinnedProfiles: [], + preflightEligibility: [], + facts: { + stage: "discover", + taskKind: "feature", + requestTerms: ["build", "feature"], + candidatePaths: [], + languages: [], + tags: [], + riskTags: [], + }, + stages: [], + gates: [], + decisions: [], + assignments: [], + prototypeCandidates: [], + prototypeCleanupCompleted: false, + deliveries: [], + commandReceipts: [], + mutatorProfileNames: [], + uiImpactEvidence: [], + affectedUiSurfaces: [], + reviewCycles: 0, + verificationCycles: 0, + }; +} + +test("plan revisions use immutable artifact paths while updating plan.md convenience", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + await store.createRun(workflow()); + const first = await store.savePlan("run-1", 1, "# First plan"); + const second = await store.savePlan("run-1", 2, "# Second plan"); + assert.notEqual(first.path, second.path); + assert.equal(await readFile(first.path, "utf8"), "# First plan\n"); + assert.equal(await readFile(second.path, "utf8"), "# Second plan\n"); + }); +}); + +test("prototype evidence is retained after throwaway source cleanup", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + await store.createRun(workflow()); + const candidate = path.join(directory, "worktree", "prototypes", "candidate-1"); + await mkdir(candidate, { recursive: true }); + const reportPath = path.join(candidate, "mobile.json"); + const screenshotPath = path.join(candidate, "mobile.png"); + await writeFile(reportPath, '{"result":"passed"}', "utf8"); + await writeFile(screenshotPath, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const artifact = await store.savePrototypeArtifact( + "run-1", + "candidate-1", + { path: "prototypes/candidate-1" }, + path.join(directory, "worktree"), + ["prototypes/candidate-1/mobile.json", "prototypes/candidate-1/mobile.png"], + ); + await rm(candidate, { recursive: true }); + assert.equal(artifact.retainedFiles.length, 2); + assert.equal(await readFile(artifact.retainedFiles[0]!, "utf8"), '{"result":"passed"}'); + await stat(artifact.path); + }); +}); + +test("terminal reports bound large persisted Decisions instead of blocking completion", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + const run = workflow(); + run.status = "completed"; + run.decisions.push({ + gateId: "gate-large", + revision: 1, + idempotencyKey: "large-decision", + decidedAt: 1, + value: { + kind: "answer-questions", + answers: { large: "x".repeat(900_000) }, + }, + }); + run.verificationEvidence = { + focusedBehavioralTests: true, + verifierSucceeded: true, + commands: Array.from({ length: 1_024 }, (_, index) => ({ + id: `command-${index}`, + executable: "node", + args: ["--test", "x".repeat(2_000)], + ownedPaths: [], + kind: "test" as const, + status: "succeeded" as const, + })), + }; + await store.createRun(run); + const report = await store.saveReport(run); + const contents = await readFile(report.path, "utf8"); + assert.match(contents, /\[truncated\]/); + assert.match(contents, /report truncated to 1 MiB/); + assert.ok(Buffer.byteLength(contents, "utf8") <= 1024 * 1024); + }); +}); + +test("supported version-zero snapshots migrate missing lifecycle collections", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + const legacy = workflow(); + const record = legacy as unknown as Record; + delete record.background; + delete record.assignments; + delete record.deliveries; + delete record.prototypeCandidates; + delete record.prototypeCleanupCompleted; + delete record.commandReceipts; + delete record.uiImpactEvidence; + delete record.affectedUiSurfaces; + delete record.reviewCycles; + delete record.verificationCycles; + const runDirectory = path.join( + directory, + "state", + "runs", + legacy.runId, + "revisions", + ); + await mkdir(runDirectory, { recursive: true }); + await writeFile( + path.join(runDirectory, "0.json"), + `${JSON.stringify({ schemaVersion: 0, run: legacy })}\n`, + "utf8", + ); + const loaded = await store.loadRun(legacy.runId); + assert.equal(loaded.background, false); + assert.deepEqual(loaded.assignments, []); + assert.deepEqual(loaded.deliveries, []); + assert.deepEqual(loaded.prototypeCandidates, []); + assert.deepEqual(loaded.commandReceipts, []); + assert.equal(loaded.prototypeCleanupCompleted, false); + }); +}); + +test("report reads reject tampering before dashboard display", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + const run = workflow(); + run.status = "completed"; + await store.createRun(run); + const report = await store.saveReport(run); + await writeFile(report.path, "tampered\n", "utf8"); + await assert.rejects( + readVerifiedReportArtifact(report), + /digest mismatch/, + ); + }); +}); + +test("delivery claims admit one process and recover only after expiry", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(path.join(directory, "state")); + await store.createRun(workflow()); + const claims = await Promise.all([ + store.claimDelivery("run-1", "gate:key", "owner-a", 1_000), + store.claimDelivery("run-1", "gate:key", "owner-b", 1_000), + ]); + assert.equal(claims.filter(Boolean).length, 1); + assert.equal( + await store.claimDelivery("run-1", "gate:key", "owner-c", 30_999), + false, + ); + assert.equal( + await store.claimDelivery("run-1", "gate:key", "owner-c", 31_000), + true, + ); + }); +}); + +test("current snapshots default newly introduced lifecycle collections", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + const legacy = workflow(); + const legacyRecord = legacy as unknown as Record; + delete legacyRecord.mutatorProfileNames; + delete legacyRecord.prototypeCleanupCompleted; + delete legacyRecord.uiImpactEvidence; + delete legacyRecord.affectedUiSurfaces; + delete legacyRecord.reviewCycles; + delete legacyRecord.verificationCycles; + await store.createRun(legacy); + const loaded = await store.loadRun(legacy.runId); + assert.deepEqual(loaded.mutatorProfileNames, []); + assert.equal(loaded.prototypeCleanupCompleted, false); + assert.deepEqual(loaded.uiImpactEvidence, []); + assert.deepEqual(loaded.affectedUiSurfaces, []); + assert.equal(loaded.reviewCycles, 0); + assert.equal(loaded.verificationCycles, 0); + }); +}); + +test("atomic snapshots leave the complete previous value when replacement is interrupted", async () => { + await withTempDir(async (directory) => { + let interrupt = false; + const store = new WorkflowStore(directory, { + beforeRename(target) { + if (interrupt && target.endsWith(path.join("revisions", "1.json"))) { + throw new Error("simulated interruption"); + } + }, + }); + const initial = workflow(); + await store.createRun(initial); + + interrupt = true; + const changed = structuredClone(initial); + changed.revision = 1; + changed.status = "waiting"; + await assert.rejects(store.saveRun(changed), /simulated interruption/); + assert.deepEqual(await store.loadRun(initial.runId), initial); + + interrupt = false; + await store.saveRun(changed); + assert.deepEqual(await store.loadRun(initial.runId), changed); + const info = await stat( + path.join(directory, "runs", initial.runId, "workflow.json"), + ); + assert.equal(info.mode & 0o777, 0o600); + }); +}); + +test("revision-checked saves serialize concurrent writers", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + const initial = workflow(); + await store.createRun(initial); + const left = structuredClone(initial); + left.revision = 1; + left.status = "waiting"; + const right = structuredClone(initial); + right.revision = 1; + right.status = "aborted"; + + const results = await Promise.allSettled([ + store.saveRun(left, 0), + store.saveRun(right, 0), + ]); + assert.equal( + results.filter((result) => result.status === "fulfilled").length, + 1, + ); + const rejected = results.find((result) => result.status === "rejected"); + assert.match( + String(rejected && rejected.status === "rejected" && rejected.reason), + /Stale workflow revision/, + ); + assert.equal((await store.loadRun(initial.runId)).revision, 1); + }); +}); + +test("a published revision survives interruption before the convenience snapshot", async () => { + await withTempDir(async (directory) => { + let interrupt = false; + const store = new WorkflowStore(directory, { + beforeRename(target) { + if (interrupt && target.endsWith("workflow.json")) { + throw new Error("simulated pointer interruption"); + } + }, + }); + const initial = workflow(); + await store.createRun(initial); + const changed = structuredClone(initial); + changed.revision = 1; + changed.status = "waiting"; + interrupt = true; + await assert.rejects(store.saveRun(changed, 0), /pointer interruption/); + assert.deepEqual(await store.loadRun(initial.runId), changed); + }); +}); + +test("run creation is atomically published and a pending start can retry", async () => { + await withTempDir(async (directory) => { + let interrupt = true; + const interruptedStore = new WorkflowStore(directory, { + beforeRename(target) { + if (interrupt && target.endsWith("request.md")) { + throw new Error("simulated creation crash"); + } + }, + }); + const initial = workflow(); + await interruptedStore.reserveStart( + "launch-key", + "fingerprint", + initial.runId, + ); + await assert.rejects( + interruptedStore.createRun(initial), + /simulated creation crash/, + ); + assert.deepEqual(await interruptedStore.listRuns(), []); + + interrupt = false; + const reservation = await interruptedStore.reserveStart( + "launch-key", + "fingerprint", + initial.runId, + ); + assert.equal(reservation.created, false); + await interruptedStore.createRun(initial); + assert.deepEqual( + (await interruptedStore.listRuns()).map((run) => run.runId), + [initial.runId], + ); + }); +}); + +test("a concurrent publisher is reloaded instead of quarantined", async () => { + await withTempDir(async (directory) => { + let markMiss: (() => void) | undefined; + let releaseMiss: (() => void) | undefined; + const missed = new Promise((resolve) => { + markMiss = resolve; + }); + const wait = new Promise((resolve) => { + releaseMiss = resolve; + }); + const slow = new WorkflowStore(directory, { + async afterRunLoadMiss() { + markMiss?.(); + await wait; + }, + }); + const fast = new WorkflowStore(directory); + const slowRun = workflow("run-race"); + slowRun.request = "Slow publisher"; + const fastRun = workflow("run-race"); + fastRun.request = "Fast publisher"; + + const slowPublication = slow.createRun(slowRun); + await missed; + const fastPublication = await fast.createRun(fastRun); + assert.equal(fastPublication.created, true); + releaseMiss?.(); + const slowResult = await slowPublication; + assert.equal(slowResult.created, false); + assert.equal(slowResult.run.request, "Fast publisher"); + assert.equal((await fast.loadRun("run-race")).request, "Fast publisher"); + }); +}); + +test("incomplete legacy run directories are skipped and fail closed", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + await store.initialize(); + const initial = workflow(); + await mkdir(path.join(directory, "runs", initial.runId), { mode: 0o700 }); + assert.deepEqual(await store.listRuns(), []); + await assert.rejects( + store.createRun(initial), + /incomplete published directory/, + ); + }); +}); + +test("start reservations are idempotent and recover an incomplete record", async () => { + await withTempDir(async (directory) => { + let interrupt = true; + const store = new WorkflowStore(directory, { + beforeRename(target) { + if (interrupt && target.endsWith("record.json")) { + throw new Error("simulated reservation crash"); + } + }, + }); + await assert.rejects( + store.reserveStart("launch-key", "fingerprint-a", "run-a"), + /simulated reservation crash/, + ); + interrupt = false; + const recovered = await store.reserveStart( + "launch-key", + "fingerprint-a", + "run-a", + ); + assert.equal(recovered.created, true); + const duplicate = await store.reserveStart( + "launch-key", + "fingerprint-a", + "run-b", + ); + assert.equal(duplicate.created, false); + assert.equal(duplicate.record.runId, "run-a"); + await assert.rejects( + store.reserveStart("launch-key", "fingerprint-b", "run-c"), + /another start command/, + ); + + const result = { runId: "run-a", revision: 3 }; + await store.completeStart("launch-key", result); + assert.deepEqual(await store.getStart("launch-key"), { + key: "launch-key", + fingerprint: "fingerprint-a", + runId: "run-a", + status: "completed", + result, + }); + }); +}); + +test("start completion keeps the highest durable run revision", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + await store.reserveStart("completion-key", "fingerprint", "run-a"); + await store.completeStart("completion-key", { + runId: "run-a", + revision: 5, + status: "waiting", + }); + await store.completeStart("completion-key", { + runId: "run-a", + revision: 2, + status: "running", + }); + const completed = await store.getStart("completion-key"); + assert.equal(completed?.status, "completed"); + assert.equal( + (completed?.result as { revision?: number } | undefined)?.revision, + 5, + ); + }); +}); + +test("concurrent start intents publish one authoritative run ID", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + const [left, right] = await Promise.all([ + store.reserveStart("claimed-key", "fingerprint", "run-left"), + store.reserveStart("claimed-key", "fingerprint", "run-right"), + ]); + assert.equal([left, right].filter((result) => result.created).length, 1); + assert.equal(left.record.runId, right.record.runId); + assert.ok( + left.record.runId === "run-left" || left.record.runId === "run-right", + ); + }); +}); + +test("run artifacts and plans are bounded, pinned, and discoverable", async () => { + await withTempDir(async (directory) => { + const store = new WorkflowStore(directory); + const first = workflow("run-a"); + const second = workflow("run-b"); + await store.createRun(first); + await store.createRun(second); + const plan = await store.savePlan( + first.runId, + 4, + "# Plan\n\nImplement it.", + ); + assert.equal(plan.revision, 4); + assert.match(plan.digest, /^[a-f0-9]{64}$/); + assert.deepEqual( + (await store.listRuns()).map((run) => run.runId), + ["run-a", "run-b"], + ); + }); +}); diff --git a/extensions/pi-workflow/store.ts b/extensions/pi-workflow/store.ts new file mode 100644 index 0000000..0cac6b0 --- /dev/null +++ b/extensions/pi-workflow/store.ts @@ -0,0 +1,924 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + link, + lstat, + mkdir, + open, + readdir, + readFile, + realpath, + rename, + rm, + stat, +} from "node:fs/promises"; +import * as path from "node:path"; +import type { + PlanArtifact, + WorkflowReportArtifact, + WorkflowRun, +} from "./domain.ts"; + +const SNAPSHOT_VERSION = 1; +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const MAX_DOCUMENT_BYTES = 1024 * 1024; +const MAX_PROTOTYPE_ARTIFACT_BYTES = 8 * 1024 * 1024; + +async function readVerifiedDocument( + artifact: { path: string; digest: string }, + label: string, + maxBytes = MAX_DOCUMENT_BYTES, +) { + const details = await stat(artifact.path); + if (!details.isFile() || details.size > maxBytes) { + throw new Error(`${label} artifact exceeds the verified document limit`); + } + const contents = await readFile(artifact.path, "utf8"); + const digest = createHash("sha256").update(contents).digest("hex"); + if (digest !== artifact.digest) { + throw new Error(`${label} artifact digest mismatch: ${artifact.path}`); + } + return contents; +} + +export function readVerifiedPlanArtifact(artifact: PlanArtifact) { + return readVerifiedDocument(artifact, "Plan"); +} + +export function readVerifiedReportArtifact(artifact: WorkflowReportArtifact) { + return readVerifiedDocument(artifact, "Report"); +} + +export function readVerifiedTranscriptArtifact(artifact: { + path: string; + digest: string; +}) { + return readVerifiedDocument(artifact, "Transcript", MAX_JSON_BYTES); +} + +interface StoredSnapshot { + schemaVersion: 0 | typeof SNAPSHOT_VERSION; + run: WorkflowRun; +} + +export interface AssignmentLeaseRecord { + token: string; + processId: number; + heartbeatAt: number; +} + +export interface StartRecord { + key: string; + fingerprint: string; + runId: string; + status: "pending" | "completed"; + result?: unknown; +} + +export interface WorkflowStoreOptions { + beforeRename?: (target: string) => void | Promise; + afterRunLoadMiss?: (runId: string) => void | Promise; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function json(value: unknown, label: string) { + const contents = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(contents) > MAX_JSON_BYTES) { + throw new Error(`${label} exceeds ${MAX_JSON_BYTES} bytes`); + } + return contents; +} + +async function syncDirectory(directory: string) { + let handle; + try { + handle = await open(directory, "r"); + await handle.sync(); + } catch { + // Some filesystems do not support directory fsync. + } finally { + await handle?.close(); + } +} + +export class WorkflowStore { + readonly root: string; + private readonly options: WorkflowStoreOptions; + + constructor(root: string, options: WorkflowStoreOptions = {}) { + this.root = root; + this.options = options; + } + + async initialize() { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await mkdir(this.runsRoot(), { recursive: true, mode: 0o700 }); + await mkdir(this.startsRoot(), { recursive: true, mode: 0o700 }); + } + + async createRun(run: WorkflowRun) { + await this.initialize(); + const directory = this.runDirectory(run.runId); + try { + return { run: await this.loadRun(run.runId), created: false as const }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await this.options.afterRunLoadMiss?.(run.runId); + + const existing = await stat(directory).catch(() => undefined); + if (existing) { + try { + return { run: await this.loadRun(run.runId), created: false as const }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + throw new Error( + `Workflow Run ${run.runId} has an incomplete published directory; inspect and remove it before retrying`, + ); + } + const staging = path.join( + this.runsRoot(), + `.${run.runId}.${process.pid}.${randomUUID()}.creating`, + ); + try { + await mkdir(staging, { mode: 0o700 }); + await this.writeInitialRun(staging, run); + await rename(staging, directory); + await syncDirectory(this.runsRoot()); + return { run, created: true as const }; + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if ( + (error as NodeJS.ErrnoException).code === "EEXIST" || + (error as NodeJS.ErrnoException).code === "ENOTEMPTY" + ) { + return { + run: await this.loadRun(run.runId), + created: false as const, + }; + } + throw error; + } + } + + async saveRun(run: WorkflowRun, expectedRevision?: number) { + const current = await this.loadRun(run.runId); + const expected = expectedRevision ?? current.revision; + if (current.revision !== expected || run.revision !== expected + 1) { + throw new Error( + `Stale workflow revision ${expected}; current revision is ${current.revision}`, + ); + } + const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run }; + const contents = json(snapshot, `workflow ${run.runId}`); + try { + await this.atomicCreate( + this.revisionPath(run.runId, run.revision), + contents, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + const latest = await this.loadRun(run.runId); + throw new Error( + `Stale workflow revision ${expected}; current revision is ${latest.revision}`, + ); + } + throw error; + } + await this.atomicWrite( + path.join(this.runDirectory(run.runId), "workflow.json"), + contents, + ); + } + + async createAssignmentLease( + runId: string, + assignmentId: string, + lease: AssignmentLeaseRecord, + ) { + const existing = await this.loadAssignmentLease(runId, assignmentId); + if (existing && existing.token !== lease.token) { + throw new Error(`Assignment ${assignmentId} already has an active lease`); + } + await this.atomicWrite( + this.assignmentLeasePath(runId, assignmentId), + json(lease, `assignment lease ${assignmentId}`), + ); + return lease; + } + + async heartbeatAssignmentLease( + runId: string, + assignmentId: string, + token: string, + heartbeatAt: number, + ) { + const existing = await this.loadAssignmentLease(runId, assignmentId); + if (!existing || existing.token !== token) return false; + await this.atomicWrite( + this.assignmentLeasePath(runId, assignmentId), + json({ ...existing, heartbeatAt }, `assignment lease ${assignmentId}`), + ); + return true; + } + + async loadAssignmentLease( + runId: string, + assignmentId: string, + ): Promise { + try { + const parsed = await readJson( + this.assignmentLeasePath(runId, assignmentId), + ); + if ( + !isRecord(parsed) || + typeof parsed.token !== "string" || + typeof parsed.processId !== "number" || + typeof parsed.heartbeatAt !== "number" + ) { + throw new Error(`Assignment ${assignmentId} has an invalid lease`); + } + return { + token: parsed.token, + processId: parsed.processId, + heartbeatAt: parsed.heartbeatAt, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + async releaseAssignmentLease( + runId: string, + assignmentId: string, + token: string, + ) { + const existing = await this.loadAssignmentLease(runId, assignmentId); + if (!existing || existing.token !== token) return false; + await rm(this.assignmentLeasePath(runId, assignmentId), { force: true }); + return true; + } + + async loadRun(runId: string): Promise { + const revisions = path.join(this.runDirectory(runId), "revisions"); + const entries = await readdir(revisions); + const latest = entries + .map((entry) => /^(\d+)\.json$/.exec(entry)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => Number(match[1])) + .sort((left, right) => right - left)[0]; + if (latest === undefined) { + throw new Error(`${revisions}: no workflow revisions`); + } + const file = this.revisionPath(runId, latest); + const parsed = await readJson(file); + if ( + !isRecord(parsed) || + (parsed.schemaVersion !== 0 && + parsed.schemaVersion !== SNAPSHOT_VERSION) || + !isRecord(parsed.run) || + parsed.run.runId !== runId + ) { + throw new Error(`${file}: unsupported or invalid workflow snapshot`); + } + // SAFETY: saveRun is the sole writer; the wrapper version and run ID were validated above. + const run = parsed.run as unknown as WorkflowRun; + run.background ??= false; + run.assignments ??= []; + run.deliveries ??= []; + run.prototypeCandidates ??= []; + run.commandReceipts ??= []; + run.mutatorProfileNames ??= []; + run.prototypeCleanupCompleted ??= false; + run.uiImpactEvidence ??= []; + run.affectedUiSurfaces ??= []; + run.reviewCycles ??= 0; + run.verificationCycles ??= 0; + for (const assignment of run.assignments) { + if (assignment.gitBefore) assignment.gitBefore.pathStatuses ??= {}; + if (assignment.gitAfter) assignment.gitAfter.pathStatuses ??= {}; + } + for (const delivery of run.deliveries) { + try { + const receipt = await readJson( + this.deliveryReceiptPath(run.runId, delivery.key), + ); + if (isRecord(receipt) && typeof receipt.sentAt === "number") { + delivery.state = "sent"; + delivery.sentAt = receipt.sentAt; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return run; + } + + async listRuns() { + await this.initialize(); + const entries = await readdir(this.runsRoot(), { withFileTypes: true }); + const runs: WorkflowRun[] = []; + for (const entry of entries.sort((left, right) => + left.name === right.name ? 0 : left.name < right.name ? -1 : 1, + )) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const snapshot = path.join(this.runsRoot(), entry.name, "workflow.json"); + try { + await stat(snapshot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + runs.push(await this.loadRun(entry.name)); + } + return runs; + } + + async saveReport(run: WorkflowRun) { + const lines = [ + `# pi-workflow ${run.runId}`, + "", + `- Status: ${run.status}`, + `- Revision: ${run.revision}`, + `- Mode: ${run.mode}`, + `- Request: ${reportText(run.request, 8_000)}`, + `- Worktree: ${run.workspace.root ?? "not required"}`, + `- Branch: ${run.workspace.branch ?? "not required"}`, + `- Plan: ${run.planArtifact?.path ?? "none"}`, + "", + "## Gates", + ...boundedReportEntries( + run.gates, + (gate) => + `- ${gate.kind}: ${gate.status} (${gate.id}, revision ${gate.revision})`, + ), + "", + "## Decisions", + ...boundedReportEntries( + run.decisions, + (decision) => + `- ${decision.gateId} revision ${decision.revision}: ${reportText(decision.value)}`, + ), + "", + "## Assignments", + ...boundedReportEntries( + run.assignments, + (assignment) => + `- ${assignment.role}/${assignment.profileName}: ${assignment.status} (${assignment.id})`, + 1_024, + ), + "", + "## Prototype Evidence", + ...boundedReportEntries( + run.prototypeCandidates, + (candidate) => + `- ${candidate.id}: ${reportText(candidate.route, 500)} | ${candidate.artifactPath}`, + ), + "", + "## Verification", + ...(run.verificationEvidence + ? boundedReportEntries( + run.verificationEvidence.commands, + (command) => + `- ${command.kind}: ${command.status} | ${reportText(`${command.executable} ${command.args.join(" ")}`, 2_000)}`, + 1_024, + ) + : ["- none"]), + "", + "## Review", + "```json", + reportText(run.reviewVerdict ?? null, 64 * 1024), + "```", + ...(run.error + ? [ + "", + "## Error", + `${run.error.code}: ${reportText(run.error.message, 8_000)}`, + ] + : []), + ]; + const contents = boundedReportDocument(lines.join("\n")); + const digest = createHash("sha256").update(contents).digest("hex"); + const reportPath = path.join( + this.runDirectory(run.runId), + "artifacts", + `report-${run.revision}-${digest}.md`, + ); + await this.atomicWrite(reportPath, contents); + return { path: reportPath, digest, revision: run.revision }; + } + + async ensureResult(run: WorkflowRun) { + try { + await stat(path.join(this.runDirectory(run.runId), "result.json")); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await this.publishResult(run); + } + + async publishResult(run: WorkflowRun) { + await this.atomicWrite( + path.join(this.runDirectory(run.runId), "result.json"), + json( + { + runId: run.runId, + revision: run.reportArtifact?.revision ?? run.revision, + snapshotRevision: run.revision, + status: run.status, + reportPath: run.reportArtifact?.path, + planPath: run.planArtifact?.path, + worktreePath: run.workspace.root, + reviewVerdict: run.reviewVerdict, + error: run.error, + }, + "workflow result", + ), + ); + } + + async savePlan(runId: string, revision: number, contents: string) { + const bounded = boundedDocument(contents, "plan"); + const digest = createHash("sha256").update(bounded).digest("hex"); + const directory = this.runDirectory(runId); + const file = path.join(directory, "plan.md"); + const versionedFile = path.join( + directory, + "artifacts", + `plan-${revision}-${digest}.md`, + ); + await this.atomicWrite(versionedFile, bounded); + await this.atomicWrite(file, bounded); + const artifact: PlanArtifact = { + path: versionedFile, + revision, + digest, + }; + return artifact; + } + + async savePrototypeArtifact( + runId: string, + candidateId: string, + evidence: unknown, + sourceRoot: string, + artifactFiles: string[], + ) { + if (!/^[a-zA-Z0-9_-]+$/.test(candidateId)) { + throw new Error("invalid prototype candidate ID"); + } + const root = await realpath(sourceRoot); + const artifactRoot = path.join( + this.runDirectory(runId), + "artifacts", + "prototypes", + candidateId, + ); + let totalBytes = 0; + const retainedFiles: string[] = []; + for (const relativePath of [...new Set(artifactFiles)]) { + if (!relativePath || path.isAbsolute(relativePath)) { + throw new Error("prototype artifact path must be relative"); + } + const target = await realpath(path.resolve(root, relativePath)); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error("prototype artifact path escapes source root"); + } + const details = await lstat(target); + if (details.isSymbolicLink() || !details.isFile()) { + throw new Error("prototype artifacts must be regular files"); + } + const contents = await readFile(target); + totalBytes += contents.byteLength; + if (totalBytes > MAX_PROTOTYPE_ARTIFACT_BYTES) { + throw new Error("prototype artifacts exceed the 8 MiB retained limit"); + } + const digest = createHash("sha256").update(contents).digest("hex"); + const destination = path.join( + artifactRoot, + `${digest}-${path.basename(relativePath)}`, + ); + await this.atomicWrite(destination, contents); + retainedFiles.push(destination); + } + const file = path.join(artifactRoot, "candidate.json"); + const contents = json( + { candidate: evidence, retainedFiles }, + `prototype candidate ${candidateId}`, + ); + await this.atomicWrite(file, contents); + return { path: file, retainedFiles }; + } + + async saveAssignmentTranscript( + runId: string, + assignmentId: string, + transcript: unknown[], + ) { + if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) { + throw new Error("invalid assignment ID"); + } + const contents = json(transcript, `assignment transcript ${assignmentId}`); + const file = path.join( + this.runDirectory(runId), + "artifacts", + "transcripts", + `${assignmentId}.json`, + ); + await this.atomicWrite(file, contents); + return { + path: file, + digest: createHash("sha256").update(contents).digest("hex"), + }; + } + + async claimDelivery( + runId: string, + key: string, + token: string, + claimedAt: number, + ) { + const claimPath = this.deliveryClaimPath(runId, key); + await mkdir(path.dirname(claimPath), { recursive: true, mode: 0o700 }); + for (let attempt = 0; attempt < 3; attempt++) { + let handle; + try { + handle = await open(claimPath, "wx", 0o600); + await handle.writeFile( + `${JSON.stringify({ key, token, claimedAt })}\n`, + "utf8", + ); + await handle.sync(); + await handle.close(); + await syncDirectory(path.dirname(claimPath)); + return true; + } catch (error) { + await handle?.close(); + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const existing = await readJson(claimPath).catch(() => undefined); + if ( + !isRecord(existing) || + typeof existing.claimedAt !== "number" || + claimedAt - existing.claimedAt < 30_000 + ) { + return false; + } + const stalePath = `${claimPath}.${randomUUID()}.stale`; + try { + await rename(claimPath, stalePath); + await rm(stalePath, { force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return false; + } + + async clearDeliveryClaim(runId: string, key: string) { + await rm(this.deliveryClaimPath(runId, key), { force: true }); + } + + async releaseDeliveryClaim(runId: string, key: string, token: string) { + const claimPath = this.deliveryClaimPath(runId, key); + const existing = await readJson(claimPath).catch(() => undefined); + if (!isRecord(existing) || existing.token !== token) return; + const releasedPath = `${claimPath}.${randomUUID()}.released`; + try { + await rename(claimPath, releasedPath); + await rm(releasedPath, { force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + async markDeliverySent(runId: string, key: string, sentAt: number) { + const run = await this.loadRun(runId); + const delivery = run.deliveries.find((candidate) => candidate.key === key); + if (!delivery || delivery.state === "sent") return run; + await this.atomicWrite( + this.deliveryReceiptPath(runId, key), + `${JSON.stringify({ key, sentAt })}\n`, + ); + delivery.state = "sent"; + delivery.sentAt = sentAt; + return run; + } + + async getStart(key: string) { + const directory = this.startDirectory(key); + try { + await stat(path.join(directory, "record.json")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + return this.readStartRecord(directory); + } + + async reserveStart(key: string, fingerprint: string, runId: string) { + await this.initialize(); + const directory = this.startDirectory(key); + await mkdir(directory, { recursive: true, mode: 0o700 }); + let created = false; + let record: StartRecord; + try { + record = await this.readStartRecord(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const candidate: StartRecord = { + key, + fingerprint, + runId, + status: "pending", + }; + try { + await this.atomicCreate( + path.join(directory, "record.json"), + json(candidate, "start record"), + ); + record = candidate; + created = true; + } catch (createError) { + if ((createError as NodeJS.ErrnoException).code !== "EEXIST") { + throw createError; + } + record = await this.readStartRecord(directory); + } + } + if (record.key !== key || record.fingerprint !== fingerprint) { + throw new Error( + "idempotency key was already used with another start command", + ); + } + return { created, claimed: true as const, record }; + } + + async completeStart(key: string, result: unknown) { + const directory = this.startDirectory(key); + const record = await this.readStartRecord(directory); + if ( + !isRecord(result) || + result.runId !== record.runId || + !Number.isInteger(result.revision) || + (result.revision as number) < 0 + ) { + throw new Error("start result does not match its Workflow Run revision"); + } + const target = path.join( + directory, + "results", + `${result.revision as number}.json`, + ); + try { + await this.atomicCreate(target, json(result, "start result")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + return this.readStartRecord(directory); + } + + async findRunByBranch(branch: string) { + const runs = await this.listRuns(); + return runs.find((run) => run.workspace.branch === branch); + } + + private async writeInitialRun(directory: string, run: WorkflowRun) { + await mkdir(path.join(directory, "profiles"), { mode: 0o700 }); + await mkdir(path.join(directory, "handoffs"), { mode: 0o700 }); + await mkdir(path.join(directory, "artifacts"), { mode: 0o700 }); + await mkdir(path.join(directory, "assignment-leases"), { mode: 0o700 }); + await mkdir(path.join(directory, "revisions"), { mode: 0o700 }); + const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run }; + const contents = json(snapshot, `workflow ${run.runId}`); + await this.atomicWrite( + path.join(directory, "revisions", "0.json"), + contents, + ); + await this.atomicWrite(path.join(directory, "workflow.json"), contents); + await this.atomicWrite( + path.join(directory, "request.md"), + boundedDocument(run.request, "request"), + ); + await this.atomicWrite( + path.join(directory, "config.json"), + json(run.effectiveConfig, "effective configuration"), + ); + for (const profile of run.pinnedProfiles) { + await this.atomicWrite( + path.join(directory, "profiles", `${profile.name}.json`), + json(profile, `profile ${profile.name}`), + ); + } + } + + private runsRoot() { + return path.join(this.root, "runs"); + } + + private startsRoot() { + return path.join(this.root, "starts"); + } + + private runDirectory(runId: string) { + if (!/^[a-zA-Z0-9_-]+$/.test(runId)) throw new Error("invalid run ID"); + return path.join(this.runsRoot(), runId); + } + + private deliveryClaimPath(runId: string, key: string) { + const digest = createHash("sha256").update(key).digest("hex"); + return path.join( + this.runDirectory(runId), + "delivery-claims", + `${digest}.json`, + ); + } + + private deliveryReceiptPath(runId: string, key: string) { + const digest = createHash("sha256").update(key).digest("hex"); + return path.join( + this.runDirectory(runId), + "delivery-receipts", + `${digest}.json`, + ); + } + + private revisionPath(runId: string, revision: number) { + if (!Number.isInteger(revision) || revision < 0) { + throw new Error("invalid workflow revision"); + } + return path.join(this.runDirectory(runId), "revisions", `${revision}.json`); + } + + private assignmentLeasePath(runId: string, assignmentId: string) { + if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) { + throw new Error("invalid assignment ID"); + } + return path.join( + this.runDirectory(runId), + "assignment-leases", + `${assignmentId}.json`, + ); + } + + private startDigest(key: string) { + return createHash("sha256").update(key).digest("hex"); + } + + private startDirectory(key: string) { + return path.join(this.startsRoot(), this.startDigest(key)); + } + + private async readStartRecord(directory: string) { + const file = path.join(directory, "record.json"); + let parsed: unknown; + try { + parsed = await readJson(file); + } catch (error) { + throw error; + } + if ( + !isRecord(parsed) || + typeof parsed.key !== "string" || + typeof parsed.fingerprint !== "string" || + typeof parsed.runId !== "string" || + (parsed.status !== "pending" && parsed.status !== "completed") + ) { + throw new Error(`${file}: invalid start record`); + } + // SAFETY: all persisted StartRecord identity and discriminator fields were validated above. + const intent = parsed as unknown as StartRecord; + const resultsDirectory = path.join(directory, "results"); + let resultEntries: string[] = []; + try { + resultEntries = await readdir(resultsDirectory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const latest = resultEntries + .map((entry) => /^(\d+)\.json$/.exec(entry)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => Number(match[1])) + .sort((left, right) => right - left)[0]; + if (latest === undefined) return { ...intent, status: "pending" as const }; + const result = await readJson( + path.join(resultsDirectory, `${latest}.json`), + ); + if ( + !isRecord(result) || + result.runId !== intent.runId || + result.revision !== latest + ) { + throw new Error(`${resultsDirectory}: invalid start result`); + } + return { ...intent, status: "completed" as const, result }; + } + + private async atomicCreate(target: string, contents: string) { + await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle; + try { + handle = await open(temporary, "wx", 0o600); + await handle.writeFile(contents, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await this.options.beforeRename?.(target); + await link(temporary, target); + await rm(temporary, { force: true }); + await syncDirectory(path.dirname(target)); + } catch (error) { + await handle?.close(); + await rm(temporary, { force: true }); + throw error; + } + } + + private async atomicWrite(target: string, contents: string | Uint8Array) { + await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle; + try { + handle = await open(temporary, "wx", 0o600); + if (typeof contents === "string") await handle.writeFile(contents, "utf8"); + else await handle.writeFile(contents); + await handle.sync(); + await handle.close(); + handle = undefined; + await this.options.beforeRename?.(target); + await rename(temporary, target); + await syncDirectory(path.dirname(target)); + } catch (error) { + await handle?.close(); + await rm(temporary, { force: true }); + throw error; + } + } +} + +function reportText(value: unknown, maxCharacters = 2_000) { + const text = typeof value === "string" ? value : JSON.stringify(value); + const characters = [...text]; + return characters.length <= maxCharacters + ? text + : `${characters.slice(0, maxCharacters).join("")}\n[truncated]`; +} + +function boundedReportEntries( + entries: T[], + render: (entry: T) => string, + limit = 128, +) { + const included = entries.slice(-limit).map(render); + if (entries.length > limit) { + included.unshift(`- [${entries.length - limit} earlier entries omitted]`); + } + return included; +} + +function boundedReportDocument(contents: string) { + const normalized = contents.endsWith("\n") ? contents : `${contents}\n`; + if (Buffer.byteLength(normalized, "utf8") <= MAX_DOCUMENT_BYTES) return normalized; + const marker = "\n[report truncated to 1 MiB]\n"; + const limit = MAX_DOCUMENT_BYTES - Buffer.byteLength(marker, "utf8"); + let bytes = 0; + const kept: string[] = []; + for (const character of normalized) { + const width = Buffer.byteLength(character, "utf8"); + if (bytes + width > limit) break; + kept.push(character); + bytes += width; + } + return `${kept.join("")}${marker}`; +} + +function boundedDocument(contents: string, label: string) { + if (Buffer.byteLength(contents) > MAX_DOCUMENT_BYTES) { + throw new Error(`${label} exceeds ${MAX_DOCUMENT_BYTES} bytes`); + } + return contents.endsWith("\n") ? contents : `${contents}\n`; +} + +async function readJson(file: string) { + const info = await stat(file); + if (info.size > MAX_JSON_BYTES) + throw new Error(`${file}: JSON file is too large`); + const contents = await readFile(file, "utf8"); + try { + return JSON.parse(contents) as unknown; + } catch (error) { + throw new Error(`${file}: invalid JSON`, { cause: error }); + } +} diff --git a/extensions/pi-workflow/tool.test.ts b/extensions/pi-workflow/tool.test.ts new file mode 100644 index 0000000..ea672e4 --- /dev/null +++ b/extensions/pi-workflow/tool.test.ts @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { WorkflowCommand, WorkflowView } from "./engine.ts"; +import { + compactWorkflowDetails, + registerPiWorkflowTool, + workflowCommandFromTool, +} from "./tool.ts"; + +function workflowView(overrides: Partial = {}): WorkflowView { + return { + runId: "run-1", + revision: 4, + status: "waiting", + workspace: { + state: "ready", + sourceRoot: "/repo", + root: "/repo/.worktrees/run-1", + branch: "feature/search", + baseHead: "abc123", + }, + uiImpactEvidence: [], + affectedUiSurfaces: [], + stages: [], + assignments: [], + mutatorProfileNames: [], + reviewCycles: 0, + verificationCycles: 0, + ...overrides, + background: overrides.background ?? false, + }; +} + +test("tool commands derive active tools for feature and analysis scripts", () => { + assert.deepEqual( + workflowCommandFromTool( + { + action: "start", + request: "Build search", + featureName: "search-page", + idempotencyKey: "feature-key", + workflow: { + mode: "feature-script", + source: "return null", + args: '{"scope":"routes"}', + insertionPoint: "before-design", + }, + }, + ["read", "grep", "bash"], + ), + { + action: "start", + request: "Build search", + featureName: "search-page", + idempotencyKey: "feature-key", + script: { + source: "return null", + args: { scope: "routes" }, + insertionPoint: "before-design", + activeToolNames: ["read", "grep", "bash"], + }, + }, + ); + const analysis = workflowCommandFromTool( + { + action: "start", + request: "Audit persistence", + workflow: { + mode: "analysis-script", + source: "return await assign('review', 'Inspect')", + }, + idempotencyKey: "analysis-key", + }, + ["read", "ls"], + ); + assert.equal(analysis.action, "start-analysis"); + assert.deepEqual( + analysis.action === "start-analysis" ? analysis.activeToolNames : [], + ["read", "ls"], + ); +}); + +test("tool commands reject open or incomplete action shapes", () => { + assert.throws( + () => + workflowCommandFromTool( + { + action: "start", + request: "Build search", + featureName: "search-page", + idempotencyKey: "feature-key", + workflow: { mode: "feature-script", source: "return null" }, + }, + ["read"], + ), + /requires workflow.source and workflow.insertionPoint/, + ); + assert.throws( + () => + workflowCommandFromTool( + { + action: "decide", + runId: "run-1", + gateId: "gate-1", + expectedRevision: 2, + idempotencyKey: "decision-key", + decision: undefined, + }, + ["read"], + ), + /decision is required/, + ); +}); + +test("compact tool details expose only valid continuation intents", () => { + const details = compactWorkflowDetails( + "inspect", + workflowView({ + openGate: { + id: "gate-1", + kind: "implementation-context", + status: "open", + revision: 4, + openedAt: 1, + }, + planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" }, + implementationContext: { + gateId: "gate-1", + runRevision: 3, + planRevision: 2, + preparationId: "prep-1", + activeParentSessionId: "session-1", + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }, + }), + ); + assert.deepEqual(details.continuation, { + command: "/workflow continue", + runId: "run-1", + }); + assert.deepEqual(details.openGate?.payload, { + preparationId: "prep-1", + planRevision: 2, + contextRevision: 3, + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + outcome: undefined, + error: undefined, + }); + assert.equal( + compactWorkflowDetails( + "inspect", + workflowView({ + openGate: { + id: "gate-1", + kind: "implementation-context", + status: "open", + revision: 4, + openedAt: 1, + }, + planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" }, + implementationContext: { + gateId: "gate-1", + runRevision: 3, + planRevision: 2, + preparationId: "prep-1", + activeParentSessionId: "session-1", + strategy: "fresh-session", + status: "handoff-pending", + method: "fresh-session", + }, + }), + "session-2", + ).continuation, + undefined, + ); + assert.equal( + compactWorkflowDetails("inspect", workflowView()).continuation, + undefined, + ); +}); + +test("tool cancellation aborts its active Workflow Run before rejecting", async () => { + let definition: Parameters[0] | undefined; + const commands: WorkflowCommand[] = []; + let finishDecision = (_view: WorkflowView) => {}; + const decision = new Promise((resolve) => { + finishDecision = resolve; + }); + const pi = { + registerTool(candidate: Parameters[0]) { + definition = candidate; + }, + getActiveTools: () => ["read"], + } as unknown as ExtensionAPI; + registerPiWorkflowTool(pi, { + store: { + async loadRun() { + return { runId: "run-1", revision: 4, status: "running" }; + }, + } as never, + async engineForContext() { + return { + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + if (command.action === "abort") { + const aborted = workflowView({ status: "aborted", revision: 5 }); + finishDecision(aborted); + return aborted; + } + return decision; + }, + } as never; + }, + }); + assert.ok(definition); + const controller = new AbortController(); + const operation = definition.execute( + "cancel-call", + { + action: "decide", + runId: "run-1", + gateId: "gate-1", + expectedRevision: 4, + idempotencyKey: "decision-key", + decision: { kind: "recover", resolution: "inspect" }, + }, + controller.signal, + undefined, + { + sessionManager: { getSessionId: () => "session-1" }, + } as unknown as ExtensionContext, + ); + controller.abort(new Error("cancelled by test")); + await assert.rejects(operation, /cancelled by test/); + assert.equal(commands.some((command) => command.action === "abort"), true); +}); + +test("registered tool executes through the context engine and injects host tools", async () => { + let definition: Parameters[0] | undefined; + const commands: WorkflowCommand[] = []; + const pi = { + registerTool(candidate: Parameters[0]) { + definition = candidate; + }, + getActiveTools() { + return ["read", "smart_compact"]; + }, + } as unknown as ExtensionAPI; + registerPiWorkflowTool(pi, { + store: {} as never, + async engineForContext() { + return { + async execute(command: WorkflowCommand) { + commands.push(structuredClone(command)); + return workflowView(); + }, + } as never; + }, + }); + assert.equal(definition?.name, "pi_workflow"); + assert.ok(definition); + await definition.execute( + "call-1", + { + action: "decide", + runId: "run-1", + gateId: "gate-1", + expectedRevision: 4, + idempotencyKey: "context-report", + decision: { + kind: "report-context-preparation", + preparationId: "prep-1", + planRevision: 2, + method: "smart_compact", + outcome: "succeeded", + }, + }, + new AbortController().signal, + undefined, + { + sessionManager: { getSessionId: () => "session-1" }, + } as unknown as ExtensionContext, + ); + assert.deepEqual( + commands[0]?.action === "decide" ? commands[0].activeToolNames : undefined, + ["read", "smart_compact"], + ); +}); diff --git a/extensions/pi-workflow/tool.ts b/extensions/pi-workflow/tool.ts new file mode 100644 index 0000000..8b0b49f --- /dev/null +++ b/extensions/pi-workflow/tool.ts @@ -0,0 +1,496 @@ +import { StringEnum } from "@earendil-works/pi-ai"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { Type, type Static } from "typebox"; +import { isValidContextContinuation } from "./continuation.ts"; +import type { GateDecision } from "./domain.ts"; +import { + executeHostDecision, + type ContextTransitionRuntime, +} from "./context-transition.ts"; +import type { WorkflowCommand, WorkflowView } from "./engine.ts"; + +const ACTIONS = ["start", "inspect", "decide", "abort"] as const; +const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const; +const INSERTION_POINTS = [ + "before-design", + "after-design", + "before-review", +] as const; +const DECISION_KINDS = [ + "answer-questions", + "approve-plan", + "revise-plan", + "accept-prototype", + "refine-prototypes", + "select-implementation-context", + "report-context-preparation", + "recover", +] as const; + +const WorkflowDefinition = Type.Object( + { + mode: StringEnum(WORKFLOW_MODES), + insertionPoint: Type.Optional(StringEnum(INSERTION_POINTS)), + source: Type.Optional(Type.String()), + args: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +const GateDecisionInput = Type.Object( + { + kind: StringEnum(DECISION_KINDS), + answers: Type.Optional(Type.Record(Type.String(), Type.String())), + approved: Type.Optional(Type.Boolean()), + acceptedUiImpact: Type.Optional( + StringEnum(["none", "minor", "non-trivial"] as const), + ), + comment: Type.Optional(Type.String()), + feedback: Type.Optional(Type.String()), + candidateId: Type.Optional(Type.String()), + candidateRevision: Type.Optional(Type.Integer({ minimum: 0 })), + browserReviewed: Type.Optional(Type.Boolean()), + strategy: Type.Optional( + StringEnum(["fresh-session", "smart-compact", "current-session"] as const), + ), + preparationId: Type.Optional(Type.String()), + planRevision: Type.Optional(Type.Integer({ minimum: 0 })), + method: Type.Optional(StringEnum(["smart_compact"] as const)), + outcome: Type.Optional( + StringEnum(["unavailable", "succeeded", "no-op", "failed"] as const), + ), + error: Type.Optional(Type.String()), + resolution: Type.Optional( + StringEnum(["retry", "inspect", "abort"] as const), + ), + }, + { additionalProperties: false }, +); + +export const PiWorkflowParameters = Type.Object( + { + action: StringEnum(ACTIONS), + request: Type.Optional(Type.String()), + featureName: Type.Optional(Type.String()), + workflow: Type.Optional(WorkflowDefinition), + concurrency: Type.Optional(Type.Integer({ minimum: 1 })), + background: Type.Optional(Type.Boolean()), + runId: Type.Optional(Type.String()), + gateId: Type.Optional(Type.String()), + expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })), + decision: Type.Optional(GateDecisionInput), + idempotencyKey: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +export type PiWorkflowInput = Static; + +function requiredString(value: unknown, label: string) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${label} is required`); + } + return value.trim(); +} + +function scriptArgs(value: string | undefined) { + if (value === undefined) return undefined; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +export function workflowCommandFromTool( + input: PiWorkflowInput, + activeToolNames: string[], +): WorkflowCommand { + if (input.action === "start") { + const request = requiredString(input.request, "request"); + const idempotencyKey = requiredString( + input.idempotencyKey, + "idempotencyKey", + ); + const definition = input.workflow ?? { mode: "feature" as const }; + if (definition.mode === "analysis-script") { + return { + action: "start-analysis", + request, + source: requiredString(definition.source, "workflow.source"), + ...(definition.args !== undefined + ? { args: scriptArgs(definition.args) } + : {}), + activeToolNames: [...activeToolNames], + idempotencyKey, + }; + } + const featureName = requiredString(input.featureName, "featureName"); + if ( + definition.mode === "feature-script" && + (!definition.insertionPoint || !definition.source) + ) { + throw new Error( + "feature-script requires workflow.source and workflow.insertionPoint", + ); + } + return { + action: "start", + request, + featureName, + idempotencyKey, + ...(input.concurrency !== undefined + ? { concurrency: input.concurrency } + : {}), + ...(input.background !== undefined + ? { background: input.background } + : {}), + ...(definition.mode === "feature-script" + ? { + script: { + source: requiredString(definition.source, "workflow.source"), + ...(definition.args !== undefined + ? { args: scriptArgs(definition.args) } + : {}), + insertionPoint: definition.insertionPoint!, + activeToolNames: [...activeToolNames], + }, + } + : {}), + }; + } + const runId = requiredString(input.runId, "runId"); + if (input.action === "inspect") return { action: "inspect", runId }; + const expectedRevision = input.expectedRevision; + if (!Number.isInteger(expectedRevision) || expectedRevision! < 0) { + throw new Error("expectedRevision is required"); + } + const idempotencyKey = requiredString( + input.idempotencyKey, + "idempotencyKey", + ); + if (input.action === "abort") { + return { + action: "abort", + runId, + expectedRevision: expectedRevision!, + idempotencyKey, + }; + } + if (!input.decision) throw new Error("decision is required"); + return { + action: "decide", + runId, + gateId: requiredString(input.gateId, "gateId"), + expectedRevision: expectedRevision!, + decision: input.decision as GateDecision, + idempotencyKey, + }; +} + +export interface WorkflowToolDetails { + action: PiWorkflowInput["action"]; + runId: string; + revision: number; + status: WorkflowView["status"]; + openGate?: { + id: string; + kind: string; + revision: number; + payload?: unknown; + }; + continuation?: { command: string; runId: string }; + planPath?: string; + worktreePath?: string; + assignmentCounts: Record; +} + +function boundedValue(value: unknown, budget = { characters: 24_000 }, depth = 0): unknown { + if (budget.characters <= 0) return "[truncated]"; + if (typeof value === "string") { + const kept = [...value].slice(0, budget.characters).join(""); + budget.characters -= [...kept].length; + return kept.length < value.length ? `${kept}[truncated]` : kept; + } + if ( + value === null || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (depth >= 6) return "[depth truncated]"; + if (Array.isArray(value)) { + return value + .slice(0, 100) + .map((entry) => boundedValue(entry, budget, depth + 1)); + } + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .slice(0, 100) + .map(([key, entry]) => [ + key.slice(0, 200), + boundedValue(entry, budget, depth + 1), + ]), + ); + } + return String(value); +} + +function boundedString(value: unknown, max = 2_000) { + if (typeof value !== "string") return undefined; + return [...value].slice(0, max).join(""); +} + +function compactGatePayload(view: WorkflowView) { + const gate = view.openGate; + const payload = gate?.payload; + if (!gate) return undefined; + if (gate.kind === "implementation-context") { + const context = view.implementationContext; + return context + ? { + preparationId: context.preparationId, + planRevision: context.planRevision, + contextRevision: context.runRevision, + strategy: context.strategy, + status: context.status, + method: context.method, + outcome: context.outcome, + error: boundedString(context.error), + } + : undefined; + } + if (!payload || typeof payload !== "object") return payload; + const record = payload as Record; + if (gate.kind === "questions") { + const questions = Array.isArray(record.questions) ? record.questions : []; + return { + questions: questions.slice(0, 20).flatMap((question) => { + if (!question || typeof question !== "object") return []; + const item = question as Record; + const id = boundedString(item.id, 160); + const prompt = boundedString(item.prompt); + return id && prompt ? [{ id, prompt }] : []; + }), + }; + } + if (gate.kind === "plan-approval") { + return { + planArtifact: boundedValue(record.planArtifact), + uiImpact: record.uiImpact, + uiImpactEvidence: Array.isArray(record.uiImpactEvidence) + ? record.uiImpactEvidence.slice(0, 20).map((entry) => boundedString(entry, 500)) + : [], + affectedUiSurfaces: Array.isArray(record.affectedUiSurfaces) + ? record.affectedUiSurfaces.slice(0, 20).map((entry) => boundedString(entry, 500)) + : [], + }; + } + if (gate.kind === "prototype-acceptance") { + const candidates = Array.isArray(record.candidates) ? record.candidates : []; + return { + candidates: candidates.slice(-3).map((candidate) => { + if (!candidate || typeof candidate !== "object") return candidate; + const item = candidate as Record; + return { + id: item.id, + revision: item.revision, + route: boundedString(item.route), + structuralRationale: boundedString(item.structuralRationale), + launchInstructions: boundedString(item.launchInstructions), + artifactPath: boundedString(item.artifactPath), + retainedArtifacts: Array.isArray(item.retainedArtifacts) + ? item.retainedArtifacts + .slice(0, 10) + .map((entry) => boundedString(entry, 1_000)) + : [], + }; + }), + }; + } + return boundedValue(record); +} + +export function compactWorkflowDetails( + action: PiWorkflowInput["action"], + view: WorkflowView, + sessionId?: string, +): WorkflowToolDetails { + const assignmentCounts: Record = {}; + const gatePayload = compactGatePayload(view); + for (const assignment of view.assignments) { + assignmentCounts[assignment.status] = + (assignmentCounts[assignment.status] ?? 0) + 1; + } + const continuation = isValidContextContinuation(view, sessionId) + ? { command: "/workflow continue", runId: view.runId } + : undefined; + return { + action, + runId: view.runId, + revision: view.revision, + status: view.status, + ...(view.openGate + ? { + openGate: { + id: view.openGate.id, + kind: view.openGate.kind, + revision: view.openGate.revision, + ...(gatePayload !== undefined + ? { payload: gatePayload } + : {}), + }, + } + : {}), + ...(continuation ? { continuation } : {}), + ...(view.planArtifact ? { planPath: view.planArtifact.path } : {}), + ...(view.workspace.root ? { worktreePath: view.workspace.root } : {}), + assignmentCounts, + }; +} + +function boundToolOutput(contents: string) { + const maxBytes = 48 * 1024; + if (Buffer.byteLength(contents, "utf8") <= maxBytes) return contents; + const marker = "\n[tool output truncated]"; + const limit = maxBytes - Buffer.byteLength(marker, "utf8"); + let bytes = 0; + const characters: string[] = []; + for (const character of contents) { + const width = Buffer.byteLength(character, "utf8"); + if (bytes + width > limit) break; + characters.push(character); + bytes += width; + } + return `${characters.join("")}${marker}`; +} + +function resultText(details: WorkflowToolDetails) { + const lines = [ + `Workflow ${details.runId}: ${details.status} at revision ${details.revision}.`, + ]; + if (details.openGate) { + lines.push( + `Open Gate: ${details.openGate.kind} (${details.openGate.id}). Obtain the user's explicit Decision before deciding it.`, + ); + if (details.openGate.payload !== undefined) { + lines.push(`Gate context: ${JSON.stringify(details.openGate.payload)}`); + } + } + if (details.continuation) { + lines.push(`${details.continuation.command} ${details.continuation.runId}`); + } + if (details.worktreePath) lines.push(`Worktree: ${details.worktreePath}`); + if (details.planPath) lines.push(`Plan: ${details.planPath}`); + return boundToolOutput(lines.join("\n")); +} + +async function cancelToolWorkflow( + runtime: ContextTransitionRuntime, + engine: Awaited>, + input: PiWorkflowInput, + toolCallId: string, +) { + let runId = input.runId; + if (!runId && input.action === "start" && input.idempotencyKey) { + for (let attempt = 0; attempt < 50; attempt++) { + const start = await runtime.store.getStart(input.idempotencyKey); + if (start) { + runId = start.runId; + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + if (!runId) return; + const current = await runtime.store.loadRun(runId).catch(() => undefined); + if ( + !current || + current.status === "completed" || + current.status === "failed" || + current.status === "aborted" + ) { + return; + } + await engine.execute({ + action: "abort", + runId, + expectedRevision: current.revision, + idempotencyKey: `tool-cancel-${toolCallId}`, + }).catch(() => {}); +} + +export function registerPiWorkflowTool( + pi: ExtensionAPI, + runtime: ContextTransitionRuntime, +) { + pi.registerTool({ + name: "pi_workflow", + label: "Pi Workflow", + description: + "Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.", + promptSnippet: + "Plan and build features through persisted Gates, worktrees, verification, and independent review", + promptGuidelines: [ + "Use pi_workflow when the user asks to plan or build a non-trivial feature through the managed workflow.", + "Never use pi_workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.", + "Use pi_workflow inspect before every state-changing call and pass its current revision and Gate ID.", + ], + parameters: PiWorkflowParameters, + async execute(toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) { + const engine = await runtime.engineForContext(ctx); + const command = workflowCommandFromTool(params, pi.getActiveTools()); + const executionSignal = signal ?? new AbortController().signal; + const operation = + command.action === "decide" + ? executeHostDecision(pi, engine, command) + : engine.execute(command); + let rejectCancellation = (_error: unknown) => {}; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + let cancellationWork: Promise | undefined; + const cancellationError = () => + executionSignal.reason instanceof Error + ? executionSignal.reason + : new Error("pi_workflow tool call was cancelled"); + const onAbort = () => { + cancellationWork = cancelToolWorkflow( + runtime, + engine, + params, + toolCallId, + ); + void cancellationWork.finally(() => { + rejectCancellation(cancellationError()); + }); + }; + executionSignal.addEventListener("abort", onAbort, { once: true }); + if (executionSignal.aborted) onAbort(); + let view: WorkflowView; + try { + view = await Promise.race([operation, cancellation]); + if (executionSignal.aborted) { + await cancellationWork; + throw cancellationError(); + } + } finally { + executionSignal.removeEventListener("abort", onAbort); + } + const details = compactWorkflowDetails( + params.action, + view, + ctx.sessionManager.getSessionId(), + ); + return { + content: [{ type: "text" as const, text: resultText(details) }], + details, + }; + }, + }); +} diff --git a/extensions/pi-workflow/ui-policy.test.ts b/extensions/pi-workflow/ui-policy.test.ts new file mode 100644 index 0000000..cd823b0 --- /dev/null +++ b/extensions/pi-workflow/ui-policy.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { WorkflowFacts } from "./domain.ts"; +import { enforceUiImpact } from "./ui-policy.ts"; + +function facts(overrides: Partial = {}): WorkflowFacts { + return { + stage: "design", + taskKind: "feature", + requestTerms: [], + candidatePaths: [], + languages: ["typescript"], + tags: [], + riskTags: [], + ...overrides, + }; +} + +test("objective non-trivial UI signals cannot be downgraded", () => { + const result = enforceUiImpact({ + plannerImpact: "minor", + plannerEvidence: ["Planner expected a small change"], + affectedSurfaces: ["Application shell"], + facts: facts({ requestTerms: ["redesign", "navigation"] }), + }); + assert.equal(result.impact, "non-trivial"); + assert.equal(result.objectiveImpact, "non-trivial"); + assert.ok(result.evidence.some((entry) => entry.includes("redesign"))); +}); + +test("layout paths and accessibility evidence require prototypes", () => { + const result = enforceUiImpact({ + plannerImpact: "none", + plannerEvidence: [], + affectedSurfaces: [], + facts: facts({ + candidatePaths: ["src/routes/layout.svelte"], + riskTags: ["accessibility"], + }), + }); + assert.equal(result.impact, "non-trivial"); + assert.equal(result.evidence.length, 2); +}); + +test("common pages, forms, modals, sidebars, and settings changes cannot bypass prototypes", () => { + for (const signal of ["page", "form", "modal", "sidebar", "settings"]) { + const result = enforceUiImpact({ + plannerImpact: "minor", + plannerEvidence: [], + affectedSurfaces: [], + facts: facts({ requestTerms: ["add", signal] }), + }); + assert.equal(result.impact, "non-trivial", signal); + } + assert.equal( + enforceUiImpact({ + plannerImpact: "minor", + plannerEvidence: [], + affectedSurfaces: [], + facts: facts({ candidatePaths: ["src/routes/account/+page.svelte"] }), + }).impact, + "non-trivial", + ); +}); + +test("backend workflow terminology does not imply a UI prototype", () => { + const result = enforceUiImpact({ + plannerImpact: "none", + plannerEvidence: ["Backend orchestration only"], + affectedSurfaces: [], + facts: facts({ + requestTerms: ["workflow", "queue"], + candidatePaths: ["src/server/workflow.ts"], + }), + }); + assert.equal(result.impact, "none"); +}); + +test("generic frontend evidence does not inflate a minor change", () => { + const result = enforceUiImpact({ + plannerImpact: "minor", + plannerEvidence: ["Adds one field"], + affectedSurfaces: ["Dashboard"], + facts: facts({ + candidatePaths: ["src/routes/dashboard.ts"], + tags: ["frontend", "ui"], + }), + }); + assert.equal(result.impact, "minor"); +}); diff --git a/extensions/pi-workflow/ui-policy.ts b/extensions/pi-workflow/ui-policy.ts new file mode 100644 index 0000000..c4f113e --- /dev/null +++ b/extensions/pi-workflow/ui-policy.ts @@ -0,0 +1,93 @@ +import type { UiImpact, WorkflowFacts } from "./domain.ts"; + +const IMPACT_RANK: Record = { + none: 0, + minor: 1, + "non-trivial": 2, +}; + +const NON_TRIVIAL_TERMS = new Set([ + "accessibility", + "a11y", + "component", + "dialog", + "editor", + "form", + "layout", + "modal", + "navigation", + "onboarding", + "page", + "redesign", + "responsive", + "settings", + "sidebar", + "wizard", +]); + +const NON_TRIVIAL_PATH_PARTS = [ + "/+page.", + "/dialog.", + "/form.", + "/layout.", + "/modal.", + "/navigation.", + "/nav.", + "/settings.", + "/shell.", + "/sidebar.", + "/accessibility/", + "/onboarding/", + "/settings/", +]; + +export interface UiImpactPolicyInput { + plannerImpact: UiImpact; + plannerEvidence: string[]; + affectedSurfaces: string[]; + facts: WorkflowFacts; +} + +export interface UiImpactPolicyResult { + impact: UiImpact; + evidence: string[]; + affectedSurfaces: string[]; + objectiveImpact: UiImpact; +} + +function objectiveEvidence(facts: WorkflowFacts) { + const evidence: string[] = []; + for (const term of facts.requestTerms) { + if (NON_TRIVIAL_TERMS.has(term.toLowerCase())) { + evidence.push(`Request includes non-trivial UI signal: ${term}`); + } + } + for (const candidatePath of facts.candidatePaths) { + const normalized = `/${candidatePath.toLowerCase().replaceAll("\\", "/")}`; + if (NON_TRIVIAL_PATH_PARTS.some((part) => normalized.includes(part))) { + evidence.push(`Affected path has non-trivial UI responsibility: ${candidatePath}`); + } + } + for (const tag of [...facts.tags, ...facts.riskTags]) { + if (NON_TRIVIAL_TERMS.has(tag.toLowerCase())) { + evidence.push(`Repository evidence includes non-trivial UI signal: ${tag}`); + } + } + return evidence; +} + +export function enforceUiImpact(input: UiImpactPolicyInput): UiImpactPolicyResult { + const objective = objectiveEvidence(input.facts); + const objectiveImpact: UiImpact = + objective.length > 0 ? "non-trivial" : input.plannerImpact; + const impact = + IMPACT_RANK[objectiveImpact] > IMPACT_RANK[input.plannerImpact] + ? objectiveImpact + : input.plannerImpact; + return { + impact, + objectiveImpact, + evidence: [...new Set([...input.plannerEvidence, ...objective])], + affectedSurfaces: [...new Set(input.affectedSurfaces)], + }; +} diff --git a/extensions/pi-workflow/workspace.test.ts b/extensions/pi-workflow/workspace.test.ts new file mode 100644 index 0000000..c59586c --- /dev/null +++ b/extensions/pi-workflow/workspace.test.ts @@ -0,0 +1,671 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { deflateSync } from "node:zlib"; +import { + ApprovedHostCommandExecutor, + FilesystemPrototypeValidator, + GitStateProbe, + GitWorkspaceManager, + MutationScope, + WorkspaceSafetyError, + WorkspaceValidationError, + type CommandResult, + type CommandRunner, +} from "./workspace.ts"; + +const execFileAsync = promisify(execFile); + +function crc32(contents: Uint8Array) { + let crc = 0xffffffff; + for (const byte of contents) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer) { + const name = Buffer.from(type, "ascii"); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.byteLength); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE(crc32(Buffer.concat([name, data]))); + return Buffer.concat([length, name, data, checksum]); +} + +function grayscalePng(width: number, height: number, value: number) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 0; + const rows = Buffer.alloc((width + 1) * height); + for (let row = 0; row < height; row++) { + rows[row * (width + 1)] = 0; + rows.fill(value, row * (width + 1) + 1, (row + 1) * (width + 1)); + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(rows)), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +async function withTempDir(run: (directory: string) => Promise) { + const directory = await mkdtemp( + path.join(tmpdir(), "pi-workflow-workspace-"), + ); + try { + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +class FakeRunner implements CommandRunner { + readonly calls: Array<{ cwd: string; executable: string; args: string[] }> = + []; + private readonly respond: (args: string[]) => CommandResult; + + constructor(respond: (args: string[]) => CommandResult) { + this.respond = respond; + } + + async run(cwd: string, executable: string, args: string[]) { + this.calls.push({ cwd, executable, args: [...args] }); + return this.respond(args); + } +} + +function result(code = 0, stdout = "", stderr = ""): CommandResult { + return { code, stdout, stderr }; +} + +function prepareResponses( + repository: string, + options: { dirty?: boolean; ignored?: boolean; branchExists?: boolean } = {}, +) { + return (args: string[]) => { + const command = args.join(" "); + if (command === "rev-parse --show-toplevel") + return result(0, `${repository}\n`); + if (command === "status --porcelain --untracked-files=normal") { + return result(0, options.dirty ? " M src/file.ts\n" : ""); + } + if (command.startsWith("check-ignore ")) { + return result(options.ignored === false ? 1 : 0); + } + if (command.startsWith("show-ref ")) { + return result(options.branchExists ? 0 : 1); + } + if (command === "rev-parse HEAD") return result(0, "abc123\n"); + if (command.startsWith("worktree add ")) return result(); + throw new Error(`Unexpected Git command: ${command}`); + }; +} + +test("mutation ownership rejects traversal, sibling prefixes, and symlinks", async () => { + await withTempDir(async (directory) => { + const root = path.join(directory, "worktree"); + const outside = path.join(directory, "outside"); + await mkdir(path.join(root, "src"), { recursive: true }); + await mkdir(outside, { recursive: true }); + const scope = await MutationScope.create(root, ["src"]); + assert.equal( + await scope.assertWritePath("src/new/file.ts"), + path.join(root, "src/new/file.ts"), + ); + await scope.mkdir("src/new"); + await scope.writeFile("src/new/file.ts", "safe\n"); + assert.equal(await readFile(path.join(root, "src/new/file.ts"), "utf8"), "safe\n"); + await assert.rejects(scope.assertWritePath("src-other/file.ts"), /outside Assignment ownership/); + await assert.rejects(scope.assertWritePath("../outside/file.ts"), /outside Assignment ownership/); + await symlink(outside, path.join(root, "src", "linked")); + await assert.rejects( + scope.assertWritePath("src/linked/file.ts"), + /symbolic link|outside the worktree/, + ); + await symlink(outside, path.join(root, "owned-link")); + const linkedScope = await MutationScope.create(root, ["owned-link"]); + await assert.rejects( + linkedScope.assertWritePath("owned-link/file.ts"), + /symbolic link|outside the worktree/, + ); + }); +}); + +test("prototype validation requires real candidates and browser evidence, then proves cleanup", async () => { + await withTempDir(async (directory) => { + const candidate = path.join(directory, "prototypes", "candidate-1"); + await mkdir(candidate, { recursive: true }); + await writeFile(path.join(candidate, "index.html"), "
Prototype
", "utf8"); + await writeFile( + path.join(candidate, "desktop.png"), + grayscalePng(1440, 900, 200), + ); + await writeFile( + path.join(candidate, "mobile.png"), + grayscalePng(390, 844, 100), + ); + await writeFile( + path.join(candidate, "desktop.json"), + JSON.stringify({ + route: "/prototype?variant=1", + result: "passed", + viewport: { width: 1440, height: 900 }, + screenshotPath: "prototypes/candidate-1/desktop.png", + }), + "utf8", + ); + await writeFile( + path.join(candidate, "mobile.json"), + JSON.stringify({ + route: "/prototype?variant=1", + result: "passed", + viewport: { width: 390, height: 844 }, + screenshotPath: "prototypes/candidate-1/mobile.png", + }), + "utf8", + ); + const validator = new FilesystemPrototypeValidator(); + const validation = await validator.validateCandidate({ + worktreeRoot: directory, + assignment: { ownedPaths: ["prototypes/candidate-1"] }, + output: { + path: "prototypes/candidate-1", + route: "/prototype?variant=1", + browserEvidence: [ + "prototypes/candidate-1/mobile.json", + "prototypes/candidate-1/desktop.json", + ], + }, + }); + assert.equal(validation.artifactFiles.length, 4); + assert.match(validation.visualDigest, /^[a-f0-9]{64}$/); + await writeFile( + path.join(candidate, "fake.png"), + Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + Buffer.from("not an image"), + ]), + ); + await writeFile( + path.join(candidate, "fake.json"), + JSON.stringify({ + route: "/prototype?variant=1", + result: "passed", + viewport: { width: 390, height: 844 }, + screenshotPath: "prototypes/candidate-1/fake.png", + }), + "utf8", + ); + await assert.rejects( + validator.validateCandidate({ + worktreeRoot: directory, + assignment: { ownedPaths: ["prototypes/candidate-1"] }, + output: { + path: "prototypes/candidate-1", + route: "/prototype?variant=1", + browserEvidence: ["prototypes/candidate-1/fake.json"], + }, + }), + /not a decodable PNG/, + ); + await assert.rejects( + validator.validateCandidate({ + worktreeRoot: directory, + assignment: { ownedPaths: ["prototypes/candidate-1"] }, + output: { + path: "prototypes/candidate-1", + route: "/prototype?variant=1", + browserEvidence: ["prototypes/candidate-1/missing.json"], + }, + }), + /missing|ENOENT/, + ); + await assert.rejects( + validator.validateCleanup({ + worktreeRoot: directory, + candidates: [{ path: "prototypes/candidate-1" }], + }), + /remains after cleanup/, + ); + await rm(candidate, { recursive: true }); + await validator.validateCleanup({ + worktreeRoot: directory, + candidates: [{ path: "prototypes/candidate-1" }], + }); + }); +}); + +test("Git probe and approved host commands enforce identity and ownership", async () => { + await withTempDir(async (directory) => { + await execFileAsync("git", ["init", "-q", directory]); + await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]); + await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]); + await mkdir(path.join(directory, "src"), { recursive: true }); + await mkdir(path.join(directory, "other"), { recursive: true }); + await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8"); + await writeFile(path.join(directory, "other", "file.ts"), "before\n", "utf8"); + await writeFile( + path.join(directory, "mutate.cjs"), + 'require("node:fs").writeFileSync(process.argv[2], "after\\n")', + "utf8", + ); + await execFileAsync("git", ["-C", directory, "add", "."]); + await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]); + const probe = new GitStateProbe(); + const initial = await probe.capture(directory); + assert.equal(initial.changedPaths.length, 0); + const executor = new ApprovedHostCommandExecutor(); + const allowed = await executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: process.execPath, + args: ["mutate.cjs", "src/file.ts"], + }); + assert.equal(allowed.result.code, 0); + assert.deepEqual(allowed.after.changedPaths, ["src/file.ts"]); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: process.execPath, + args: ["mutate.cjs", "other/file.ts"], + }), + /outside ownership: other\/file.ts/, + ); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: "git", + args: ["reset", "--hard"], + }), + /Git host commands are not allowed/, + ); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: "git", + args: ["-C", ".", "reset", "--hard"], + }), + /Git host commands are not allowed/, + ); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: "git", + args: ["-c", "alias.x=commit", "x", "-m", "bypass"], + }), + /Git host commands are not allowed/, + ); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: "env", + args: ["git", "clean", "-fd"], + }), + /cannot invoke a command wrapper or shell/, + ); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: process.execPath, + args: ["-e", 'require("node:child_process").execFileSync("git")'], + }), + /cannot execute inline interpreter code/, + ); + await assert.rejects( + executor.run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: "sh", + args: ["-c", "touch src/shell-created"], + }), + /cannot invoke a command wrapper or shell/, + ); + }); +}); + +test("Git change detection excludes unchanged pre-existing dirty paths", async () => { + await withTempDir(async (directory) => { + await execFileAsync("git", ["init", "-q", directory]); + await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]); + await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]); + await mkdir(path.join(directory, "src"), { recursive: true }); + await writeFile(path.join(directory, "src", "existing.ts"), "base\n", "utf8"); + await writeFile(path.join(directory, "src", "generated.ts"), "base\n", "utf8"); + await execFileAsync("git", ["-C", directory, "add", "."]); + await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]); + await writeFile(path.join(directory, "src", "existing.ts"), "dirty\n", "utf8"); + const probe = new GitStateProbe(); + const before = await probe.capture(directory); + await writeFile(path.join(directory, "src", "generated.ts"), "generated\n", "utf8"); + const after = await probe.capture(directory); + assert.deepEqual(probe.changedSince(before, after), ["src/generated.ts"]); + }); +}); + +test("post-command Git probe failures are workspace safety failures", async () => { + await withTempDir(async (directory) => { + await execFileAsync("git", ["init", "-q", directory]); + await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]); + await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]); + await mkdir(path.join(directory, "src"), { recursive: true }); + await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8"); + await writeFile( + path.join(directory, "corrupt.cjs"), + 'require("node:fs").renameSync(".git", ".git-broken")', + "utf8", + ); + await execFileAsync("git", ["-C", directory, "add", "."]); + await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]); + await assert.rejects( + new ApprovedHostCommandExecutor().run({ + worktreeRoot: directory, + ownedPaths: ["src"], + executable: process.execPath, + args: ["corrupt.cjs"], + }), + (error: unknown) => + error instanceof WorkspaceSafetyError && + /Unable to verify Git safety after host command/.test(error.message), + ); + }); +}); + +test("workspace preparation validates Git state and builds the exact feature branch", async () => { + await withTempDir(async (repository) => { + const runner = new FakeRunner(prepareResponses(repository)); + const manager = new GitWorkspaceManager(runner); + const plan = await manager.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "search-dashboard", + runId: "run-1", + }); + assert.equal(plan.branch, "feature/search-dashboard"); + assert.equal( + plan.worktreePath, + path.join(repository, ".worktrees", "pi-workflow-search-dashboard-run-1"), + ); + assert.equal(plan.baseHead, "abc123"); + assert.equal(plan.branchExists, false); + + const workspace = await manager.create(plan); + assert.equal(workspace.state, "ready"); + assert.deepEqual(runner.calls.at(-1)?.args, [ + "worktree", + "add", + "-b", + "feature/search-dashboard", + plan.worktreePath, + "abc123", + ]); + assert.ok(runner.calls.every((call) => call.executable === "git")); + }); +}); + +test("real Git creation and reconciliation preserve branch and HEAD identity", async () => { + await withTempDir(async (repository) => { + const git = (args: string[]) => + execFileAsync("git", args, { cwd: repository, encoding: "utf8" }); + await git(["init", "-b", "main"]); + await git(["config", "user.email", "pi-workflow@example.invalid"]); + await git(["config", "user.name", "Pi Workflow Test"]); + await writeFile( + path.join(repository, ".gitignore"), + ".worktrees/\n", + "utf8", + ); + await writeFile(path.join(repository, "README.md"), "# Fixture\n", "utf8"); + await git(["add", ".gitignore", "README.md"]); + await git(["commit", "-m", "initial fixture"]); + + const manager = new GitWorkspaceManager(); + const plan = await manager.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "real-feature", + runId: "run-1", + }); + const workspace = await manager.create(plan); + assert.equal(workspace.state, "ready"); + assert.equal( + ( + await manager.reconcile({ + ...workspace, + state: "creating", + }) + ).state, + "ready", + ); + const branch = await execFileAsync("git", ["branch", "--show-current"], { + cwd: plan.worktreePath, + encoding: "utf8", + }); + assert.equal(branch.stdout.trim(), "feature/real-feature"); + }); +}); + +test("dirty sources and unignored worktree roots fail before creation", async () => { + await withTempDir(async (repository) => { + const dirty = new GitWorkspaceManager( + new FakeRunner(prepareResponses(repository, { dirty: true })), + ); + await assert.rejects( + dirty.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "feature", + runId: "run-1", + }), + (error) => + error instanceof WorkspaceValidationError && + error.code === "dirty-source", + ); + + const unignored = new GitWorkspaceManager( + new FakeRunner(prepareResponses(repository, { ignored: false })), + ); + await assert.rejects( + unignored.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "feature", + runId: "run-1", + }), + (error) => + error instanceof WorkspaceValidationError && + error.code === "unignored-root", + ); + }); +}); + +test("workspace preparation rejects a symlinked worktree root", async () => { + await withTempDir(async (repository) => { + const outside = await mkdtemp(path.join(tmpdir(), "pi-workflow-outside-")); + try { + await symlink(outside, path.join(repository, ".worktrees")); + const manager = new GitWorkspaceManager( + new FakeRunner(prepareResponses(repository)), + ); + await assert.rejects( + manager.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "feature", + runId: "run-1", + }), + (error) => + error instanceof WorkspaceValidationError && + error.code === "unignored-root" && + /symlink/.test(error.message), + ); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); +}); + +test("workspace preparation reports branch collisions and existing paths", async () => { + await withTempDir(async (repository) => { + const manager = new GitWorkspaceManager( + new FakeRunner(prepareResponses(repository, { branchExists: true })), + ); + const plan = await manager.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "feature", + runId: "run-1", + }); + assert.equal(plan.branchExists, true); + + await mkdir(plan.worktreePath, { recursive: true }); + await assert.rejects( + manager.prepare({ + sourceRoot: repository, + configuredRoot: ".worktrees", + featureName: "feature", + runId: "run-1", + }), + (error) => + error instanceof WorkspaceValidationError && + error.code === "existing-worktree-path", + ); + }); +}); + +test("interrupted workspace creation reconciles only an exact path and branch", async () => { + await withTempDir(async (repository) => { + const root = path.join(repository, ".worktrees", "run-1"); + const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`; + const manager = new GitWorkspaceManager( + new FakeRunner((args) => + args.join(" ") === "worktree list --porcelain" + ? result(0, listed) + : result(1, "", "unexpected"), + ), + ); + const workspace = { + state: "creating" as const, + sourceRoot: repository, + root, + branch: "feature/feature", + baseHead: "abc123", + }; + assert.equal((await manager.reconcile(workspace)).state, "ready"); + assert.equal( + ( + await manager.reconcile({ + ...workspace, + branch: "feature/other", + }) + ).state, + "failed", + ); + assert.equal( + (await manager.reconcile({ ...workspace, baseHead: "different" })).state, + "failed", + ); + assert.equal( + (await manager.reconcile(workspace, path.join(repository, "other"))) + .state, + "failed", + ); + + const absent = new GitWorkspaceManager(new FakeRunner(() => result(0, ""))); + assert.equal((await absent.reconcile(workspace)).state, "creating"); + }); +}); + +test("uncertain worktree-add results are reconciled before returning", async () => { + await withTempDir(async (repository) => { + const root = path.join(repository, ".worktrees", "run-1"); + const plan = { + sourceRoot: repository, + worktreeRoot: path.dirname(root), + worktreePath: root, + branch: "feature/feature", + baseHead: "abc123", + branchExists: false, + }; + const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`; + const partial = new GitWorkspaceManager( + new FakeRunner((args) => { + if (args[0] === "show-ref") return result(1); + return args[0] === "worktree" && args[1] === "add" + ? result(128, "", "interrupted") + : result(0, listed); + }), + ); + assert.equal((await partial.create(plan)).state, "ready"); + + const absent = new GitWorkspaceManager( + new FakeRunner((args) => { + if (args[0] === "show-ref") return result(1); + return args[0] === "worktree" && args[1] === "add" + ? result(128, "", "interrupted") + : result(0, ""); + }), + ); + const uncertain = await absent.create(plan); + assert.equal(uncertain.state, "creating"); + assert.match(uncertain.error ?? "", /returned 128/); + }); +}); + +test("retry adopts an exact orphan branch left by interrupted creation", async () => { + await withTempDir(async (repository) => { + const root = path.join(repository, ".worktrees", "run-1"); + const runner = new FakeRunner((args) => { + if (args[0] === "show-ref") return result(0); + if (args[0] === "rev-parse") return result(0, "abc123\n"); + if (args[0] === "worktree" && args[1] === "add") return result(0); + return result(1, "", "unexpected"); + }); + const manager = new GitWorkspaceManager(runner); + const plan = { + sourceRoot: repository, + worktreeRoot: path.dirname(root), + worktreePath: root, + branch: "feature/feature", + baseHead: "abc123", + branchExists: false, + }; + assert.equal((await manager.create(plan)).state, "ready"); + assert.deepEqual(runner.calls.at(-1)?.args, [ + "worktree", + "add", + root, + "feature/feature", + ]); + + const mismatch = new GitWorkspaceManager( + new FakeRunner((args) => + args[0] === "show-ref" ? result(0) : result(0, "different\n"), + ), + ); + assert.equal((await mismatch.create(plan)).state, "failed"); + }); +}); diff --git a/extensions/pi-workflow/workspace.ts b/extensions/pi-workflow/workspace.ts new file mode 100644 index 0000000..ae95c0f --- /dev/null +++ b/extensions/pi-workflow/workspace.ts @@ -0,0 +1,1107 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { + lstat, + mkdir, + open, + readFile, + readlink, + realpath, + rm, +} from "node:fs/promises"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { inflateSync } from "node:zlib"; +import type { WorkflowWorkspace } from "./domain.ts"; + +const execFileAsync = promisify(execFile); + +export interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +export interface CommandRunner { + run(cwd: string, executable: string, args: string[]): Promise; +} + +export class WorkspaceSafetyError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkspaceSafetyError"; + } +} + +export interface GitStateSnapshot { + root: string; + branch: string; + head: string; + commonDirectory: string; + gitDirectory: string; + worktreeListDigest: string; + worktreeMetadataDigest: string; + status: string; + changedPaths: string[]; + pathStatuses: Record; + pathDigests: Record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pngCrc32(contents: Uint8Array) { + let crc = 0xffffffff; + for (const byte of contents) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function paethPredictor(left: number, above: number, upperLeft: number) { + const estimate = left + above - upperLeft; + const leftDistance = Math.abs(estimate - left); + const aboveDistance = Math.abs(estimate - above); + const upperLeftDistance = Math.abs(estimate - upperLeft); + if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left; + return aboveDistance <= upperLeftDistance ? above : upperLeft; +} + +function parsePngVisual(contents: Buffer) { + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + if ( + contents.byteLength < signature.byteLength || + !contents.subarray(0, signature.byteLength).equals(signature) + ) { + throw new Error("invalid PNG signature"); + } + let offset = signature.byteLength; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = -1; + let palette = Buffer.alloc(0); + const compressed: Buffer[] = []; + let sawHeader = false; + let sawEnd = false; + while (offset < contents.byteLength) { + if (offset + 12 > contents.byteLength) throw new Error("truncated PNG chunk"); + const length = contents.readUInt32BE(offset); + const end = offset + 12 + length; + if (end > contents.byteLength) throw new Error("truncated PNG data"); + const type = contents.subarray(offset + 4, offset + 8); + const data = contents.subarray(offset + 8, offset + 8 + length); + const expectedCrc = contents.readUInt32BE(offset + 8 + length); + if (pngCrc32(Buffer.concat([type, data])) !== expectedCrc) { + throw new Error("invalid PNG checksum"); + } + const name = type.toString("ascii"); + if (name === "IHDR") { + if (sawHeader || length !== 13 || offset !== signature.byteLength) { + throw new Error("invalid PNG header"); + } + sawHeader = true; + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8] ?? 0; + colorType = data[9] ?? -1; + if ( + width < 1 || + height < 1 || + width > 16_384 || + height > 16_384 || + data[10] !== 0 || + data[11] !== 0 || + data[12] !== 0 + ) { + throw new Error("unsupported PNG header"); + } + } else if (name === "IDAT") { + compressed.push(Buffer.from(data)); + } else if (name === "PLTE" || name === "tRNS") { + palette = Buffer.concat([palette, Buffer.from(type), Buffer.from(data)]); + } else if (name === "IEND") { + if (length !== 0) throw new Error("invalid PNG end chunk"); + sawEnd = true; + offset = end; + break; + } + offset = end; + } + if (!sawHeader || !sawEnd || compressed.length === 0 || offset !== contents.byteLength) { + throw new Error("incomplete PNG image"); + } + const channels = new Map([ + [0, 1], + [2, 3], + [3, 1], + [4, 2], + [6, 4], + ]).get(colorType); + if (!channels || ![1, 2, 4, 8, 16].includes(bitDepth)) { + throw new Error("unsupported PNG color format"); + } + const rowBytes = Math.ceil((width * channels * bitDepth) / 8); + const bytesPerPixel = Math.max(1, Math.ceil((channels * bitDepth) / 8)); + const filtered = inflateSync(Buffer.concat(compressed), { + maxOutputLength: (rowBytes + 1) * height, + }); + if (filtered.byteLength !== (rowBytes + 1) * height) { + throw new Error("invalid PNG scanline length"); + } + const pixels = Buffer.alloc(rowBytes * height); + for (let row = 0; row < height; row++) { + const sourceOffset = row * (rowBytes + 1); + const targetOffset = row * rowBytes; + const filter = filtered[sourceOffset]; + if (filter === undefined || filter > 4) throw new Error("invalid PNG filter"); + for (let column = 0; column < rowBytes; column++) { + const encoded = filtered[sourceOffset + 1 + column] ?? 0; + const left = column >= bytesPerPixel ? (pixels[targetOffset + column - bytesPerPixel] ?? 0) : 0; + const above = row > 0 ? (pixels[targetOffset - rowBytes + column] ?? 0) : 0; + const upperLeft = + row > 0 && column >= bytesPerPixel + ? (pixels[targetOffset - rowBytes + column - bytesPerPixel] ?? 0) + : 0; + const predictor = + filter === 0 + ? 0 + : filter === 1 + ? left + : filter === 2 + ? above + : filter === 3 + ? Math.floor((left + above) / 2) + : paethPredictor(left, above, upperLeft); + pixels[targetOffset + column] = (encoded + predictor) & 0xff; + } + } + return { + width, + height, + digest: createHash("sha256") + .update(`${width}:${height}:${bitDepth}:${colorType}\n`) + .update(palette) + .update(pixels) + .digest("hex"), + }; +} + +function isWithin(root: string, candidate: string) { + return candidate === root || candidate.startsWith(`${root}${path.sep}`); +} + +async function nearestExisting(candidate: string, floor: string) { + let current = candidate; + while (isWithin(floor, current)) { + try { + await lstat(current); + return current; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (current === floor) break; + current = path.dirname(current); + } + throw new Error(`No existing ancestor for ${candidate} is inside ${floor}`); +} + +export class MutationScope { + readonly root: string; + readonly ownedRoots: string[]; + + private constructor(root: string, ownedRoots: string[]) { + this.root = root; + this.ownedRoots = ownedRoots; + } + + static async create(worktreeRoot: string, ownedPaths: string[]) { + if (ownedPaths.length === 0) throw new Error("Mutation ownership cannot be empty"); + const root = await realpath(path.resolve(worktreeRoot)); + const ownedRoots = ownedPaths.map((ownedPath) => { + if (!ownedPath || path.isAbsolute(ownedPath)) { + throw new Error(`Owned path must be relative: ${ownedPath}`); + } + const resolved = path.resolve(root, ownedPath); + if (!isWithin(root, resolved)) { + throw new Error(`Owned path escapes the worktree: ${ownedPath}`); + } + return resolved; + }); + return new MutationScope(root, [...new Set(ownedRoots)]); + } + + async remove(targetPath: string) { + const target = await this.assertWritePath(targetPath); + await rm(target, { recursive: true, force: true }); + } + + owns(candidate: string) { + const absolute = path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(this.root, candidate); + return ( + isWithin(this.root, absolute) && + this.ownedRoots.some((ownedRoot) => isWithin(ownedRoot, absolute)) + ); + } + + async assertWritePath(candidate: string) { + const absolute = path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(this.root, candidate); + if (!this.owns(absolute)) { + throw new Error(`Write path is outside Assignment ownership: ${candidate}`); + } + const ancestor = await nearestExisting(absolute, this.root); + const canonicalAncestor = await realpath(ancestor); + if (!isWithin(this.root, canonicalAncestor)) { + throw new Error(`Write path resolves outside the worktree: ${candidate}`); + } + let current = this.root; + const relative = path.relative(this.root, absolute); + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + try { + const details = await lstat(current); + if (details.isSymbolicLink()) { + throw new Error(`Write path traverses a symbolic link: ${candidate}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + } + return absolute; + } + + async writeFile(targetPath: string, content: string) { + const target = await this.assertWritePath(targetPath); + const handle = await open( + target, + constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o666, + ); + try { + const descriptorPath = await realpath(`/proc/self/fd/${handle.fd}`).catch( + () => realpath(target), + ); + if (!this.owns(descriptorPath)) { + throw new WorkspaceSafetyError( + `Opened write target escapes worktree ownership: ${targetPath}`, + ); + } + await handle.truncate(0); + await handle.writeFile(content, "utf8"); + } finally { + await handle.close(); + } + } + + async mkdir(targetPath: string) { + const target = await this.assertWritePath(targetPath); + const relative = path.relative(this.root, target); + let current = this.root; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + await mkdir(current).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error; + }); + const canonical = await realpath(current); + if (this.owns(current) && !this.owns(canonical)) { + throw new WorkspaceSafetyError( + `Created directory escapes worktree ownership: ${targetPath}`, + ); + } + } + } +} + +async function pathDigest(root: string, relativePath: string) { + const target = path.resolve(root, relativePath); + try { + const details = await lstat(target); + if (details.isSymbolicLink()) { + return `symlink:${await readlink(target)}`; + } + if (details.isDirectory()) return "directory"; + const content = await readFile(target); + return `file:${createHash("sha256").update(content).digest("hex")}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; + } +} + +function changedPathStatuses(status: string) { + const entries = status.split("\0"); + const statuses: Record = {}; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (!entry) continue; + const code = entry.slice(0, 2); + const file = entry.slice(3); + if (file) statuses[file] = code; + if (code.includes("R") || code.includes("C")) { + const renamed = entries[++index]; + if (renamed) statuses[renamed] = code; + } + } + return statuses; +} + +export class GitStateProbe { + private readonly runner: CommandRunner; + + constructor(runner: CommandRunner = new ExecFileCommandRunner()) { + this.runner = runner; + } + + async captureRepository(worktreeRoot: string) { + const root = await realpath(path.resolve(worktreeRoot)); + const listing = await this.git(root, ["worktree", "list", "--porcelain"]); + const roots = listing + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length).trim()); + const snapshots = new Map(); + for (const candidate of roots) { + try { + const snapshot = await this.capture(candidate); + snapshots.set(snapshot.root, snapshot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return snapshots; + } + + async capture(worktreeRoot: string): Promise { + const root = await realpath(path.resolve(worktreeRoot)); + const [branch, head, common, gitDirectory, worktreeList, status] = await Promise.all([ + this.git(root, ["branch", "--show-current"]), + this.git(root, ["rev-parse", "HEAD"]), + this.git(root, ["rev-parse", "--git-common-dir"]), + this.git(root, ["rev-parse", "--git-dir"]), + this.git(root, ["worktree", "list", "--porcelain"]), + this.git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), + ]); + const commonDirectory = path.resolve(root, common.trim()); + const resolvedGitDirectory = path.resolve(root, gitDirectory.trim()); + const metadataHash = createHash("sha256"); + for (const metadataPath of [ + path.join(commonDirectory, "config"), + path.join(resolvedGitDirectory, "gitdir"), + path.join(resolvedGitDirectory, "commondir"), + path.join(resolvedGitDirectory, "HEAD"), + path.join(resolvedGitDirectory, "locked"), + path.join(resolvedGitDirectory, "config.worktree"), + ]) { + metadataHash.update(metadataPath); + try { + metadataHash.update(await readFile(metadataPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + metadataHash.update("missing"); + } + } + const pathStatuses = changedPathStatuses(status); + const paths = Object.keys(pathStatuses).sort(); + const pathDigests = Object.fromEntries( + await Promise.all( + paths.map(async (changed) => [changed, await pathDigest(root, changed)] as const), + ), + ); + return { + root, + branch: branch.trim(), + head: head.trim(), + commonDirectory, + gitDirectory: resolvedGitDirectory, + worktreeListDigest: createHash("sha256") + .update(worktreeList) + .digest("hex"), + worktreeMetadataDigest: metadataHash.digest("hex"), + status, + changedPaths: paths, + pathStatuses, + pathDigests, + }; + } + + changedSince(before: GitStateSnapshot, after: GitStateSnapshot) { + const paths = [...new Set([...before.changedPaths, ...after.changedPaths])]; + return paths.filter( + (changed) => + before.pathStatuses[changed] !== after.pathStatuses[changed] || + before.pathDigests[changed] !== after.pathDigests[changed], + ); + } + + assertIdentity(before: GitStateSnapshot, after: GitStateSnapshot) { + if ( + before.root !== after.root || + before.branch !== after.branch || + before.head !== after.head || + before.commonDirectory !== after.commonDirectory || + before.gitDirectory !== after.gitDirectory || + before.worktreeListDigest !== after.worktreeListDigest || + before.worktreeMetadataDigest !== after.worktreeMetadataDigest + ) { + throw new WorkspaceSafetyError( + "Git branch, HEAD, or worktree identity changed during mutation", + ); + } + } + + private async git(cwd: string, args: string[]) { + const result = await this.runner.run(cwd, "git", args); + if (result.code !== 0) { + throw new Error(`Git state probe failed: ${result.stderr.trim()}`); + } + return result.stdout; + } +} + +const SHELL_EXECUTABLES = new Set([ + "bash", + "cmd", + "cmd.exe", + "fish", + "powershell", + "pwsh", + "sh", + "zsh", +]); + +const COMMAND_WRAPPERS = new Set(["env", "find", "npx", "xargs"]); +const INLINE_EVALUATION_FLAGS = new Map>([ + ["node", new Set(["-e", "--eval", "-p", "--print"])], + ["python", new Set(["-c"])], + ["python3", new Set(["-c"])], + ["ruby", new Set(["-e"])], + ["perl", new Set(["-e", "-E"])], +]); +const PACKAGE_EXEC_SUBCOMMANDS = new Map>([ + ["npm", new Set(["exec", "x"])], + ["pnpm", new Set(["dlx", "exec"])], + ["yarn", new Set(["dlx", "exec"])], + ["bun", new Set(["x"])], +]); + +export class ApprovedHostCommandExecutor { + private readonly runner: CommandRunner; + private readonly probe: GitStateProbe; + + constructor( + runner: CommandRunner = new ExecFileCommandRunner(), + probe: GitStateProbe = new GitStateProbe(runner), + ) { + this.runner = runner; + this.probe = probe; + } + + async run(options: { + worktreeRoot: string; + ownedPaths: string[]; + executable: string; + args: string[]; + }) { + if (!options.executable || options.args.some((argument) => argument.includes("\0"))) { + throw new Error("Host commands require a fixed executable and argument array"); + } + const executableName = path.basename(options.executable).toLowerCase(); + if ( + SHELL_EXECUTABLES.has(executableName) || + COMMAND_WRAPPERS.has(executableName) + ) { + throw new Error("Host commands cannot invoke a command wrapper or shell"); + } + const evaluationFlags = INLINE_EVALUATION_FLAGS.get(executableName); + if (evaluationFlags && options.args.some((argument) => evaluationFlags.has(argument))) { + throw new Error("Host commands cannot execute inline interpreter code"); + } + const packageExecCommands = PACKAGE_EXEC_SUBCOMMANDS.get(executableName); + if (packageExecCommands?.has(options.args[0] ?? "")) { + throw new Error("Host commands cannot invoke package-manager executable wrappers"); + } + if (executableName === "git") { + throw new Error( + "Git host commands are not allowed; workspace Git operations are managed internally", + ); + } + const root = await realpath(path.resolve(options.worktreeRoot)); + const scope = + options.ownedPaths.length > 0 + ? await MutationScope.create(root, options.ownedPaths) + : undefined; + const beforeRepository = await this.probe.captureRepository(root); + const before = beforeRepository.get(root) ?? (await this.probe.capture(root)); + const result = await this.runner.run( + root, + options.executable, + [...options.args], + ); + try { + const afterRepository = await this.probe.captureRepository(root); + const after = afterRepository.get(root) ?? (await this.probe.capture(root)); + this.probe.assertIdentity(before, after); + for (const [repositoryRoot, repositoryBefore] of beforeRepository) { + if (repositoryRoot === root) continue; + const repositoryAfter = afterRepository.get(repositoryRoot); + if (!repositoryAfter) { + throw new WorkspaceSafetyError( + `Host command removed repository worktree ${repositoryRoot}`, + ); + } + this.probe.assertIdentity(repositoryBefore, repositoryAfter); + const touched = this.probe.changedSince(repositoryBefore, repositoryAfter); + if (touched.length > 0) { + throw new WorkspaceSafetyError( + `Host command changed another repository worktree: ${repositoryRoot}`, + ); + } + } + const touchedPaths = this.probe.changedSince(before, after); + const outOfScope = scope + ? touchedPaths.filter((changed) => !scope.owns(changed)) + : touchedPaths; + if (outOfScope.length > 0) { + throw new WorkspaceSafetyError( + `Host command changed paths outside ownership: ${outOfScope.join(", ")}`, + ); + } + return { result, before, after }; + } catch (error) { + if (error instanceof WorkspaceSafetyError) throw error; + throw new WorkspaceSafetyError( + `Unable to verify Git safety after host command: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export class FilesystemPrototypeValidator { + async validateCandidate(options: { + worktreeRoot: string; + assignment: { ownedPaths: string[] }; + output: { path: string; route: string; browserEvidence: string[] }; + }) { + const scope = await MutationScope.create( + options.worktreeRoot, + options.assignment.ownedPaths, + ); + const candidatePath = await scope.assertWritePath(options.output.path); + const candidate = await lstat(candidatePath); + if (candidate.isSymbolicLink() || (!candidate.isDirectory() && !candidate.isFile())) { + throw new WorkspaceSafetyError("Prototype candidate path is not a real file or directory"); + } + const widths: number[] = []; + const artifactFiles: string[] = []; + const screenshotDigests: string[] = []; + for (const evidencePath of options.output.browserEvidence) { + const absoluteEvidence = await scope.assertWritePath(evidencePath); + const evidence = await lstat(absoluteEvidence); + if ( + evidence.isSymbolicLink() || + !evidence.isFile() || + evidence.size === 0 || + evidence.size > 64 * 1024 + ) { + throw new WorkspaceSafetyError( + `Prototype browser evidence is missing or invalid: ${evidencePath}`, + ); + } + let report: unknown; + try { + report = JSON.parse(await readFile(absoluteEvidence, "utf8")); + } catch { + throw new WorkspaceSafetyError( + `Prototype browser evidence must be a JSON validation report: ${evidencePath}`, + ); + } + if ( + !isRecord(report) || + report.route !== options.output.route || + report.result !== "passed" || + !isRecord(report.viewport) || + !Number.isSafeInteger(report.viewport.width) || + !Number.isSafeInteger(report.viewport.height) || + typeof report.screenshotPath !== "string" + ) { + throw new WorkspaceSafetyError( + `Prototype browser evidence report is invalid: ${evidencePath}`, + ); + } + const screenshotPath = await scope.assertWritePath(report.screenshotPath); + const screenshot = await lstat(screenshotPath); + if ( + screenshot.isSymbolicLink() || + !screenshot.isFile() || + screenshot.size === 0 + ) { + throw new WorkspaceSafetyError( + `Prototype browser screenshot is invalid: ${report.screenshotPath}`, + ); + } + const screenshotContents = await readFile(screenshotPath); + let visual: ReturnType; + try { + visual = parsePngVisual(screenshotContents); + } catch (error) { + throw new WorkspaceSafetyError( + `Prototype browser screenshot is not a decodable PNG: ${report.screenshotPath} (${error instanceof Error ? error.message : String(error)})`, + ); + } + if ( + visual.width !== report.viewport.width || + visual.height !== report.viewport.height + ) { + throw new WorkspaceSafetyError( + `Prototype screenshot dimensions do not match its viewport: ${report.screenshotPath}`, + ); + } + artifactFiles.push(evidencePath, report.screenshotPath); + screenshotDigests.push(visual.digest); + widths.push(report.viewport.width); + } + if (!widths.some((width) => width <= 480) || !widths.some((width) => width >= 1024)) { + throw new WorkspaceSafetyError( + "Prototype browser evidence must include passed mobile and desktop viewports", + ); + } + return { + artifactFiles: [...new Set(artifactFiles)], + visualDigest: createHash("sha256") + .update(screenshotDigests.sort().join("\n")) + .digest("hex"), + }; + } + + async validateCleanup(options: { + worktreeRoot: string; + candidates: Array<{ path: string }>; + }) { + const root = await realpath(path.resolve(options.worktreeRoot)); + for (const candidate of options.candidates) { + const target = path.resolve(root, candidate.path); + if (!isWithin(root, target)) { + throw new WorkspaceSafetyError("Prototype cleanup candidate escapes worktree"); + } + try { + await lstat(target); + throw new WorkspaceSafetyError( + `Prototype-only path remains after cleanup: ${candidate.path}`, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + } +} + +export class GitDiffProvider { + private readonly runner: CommandRunner; + + constructor(runner: CommandRunner = new ExecFileCommandRunner()) { + this.runner = runner; + } + + async read(worktreeRoot: string, baseHead = "HEAD") { + const root = await realpath(path.resolve(worktreeRoot)); + const diff = await this.runner.run(root, "git", [ + "diff", + "--binary", + "--no-ext-diff", + baseHead, + "--", + ]); + if (diff.code !== 0) { + throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`); + } + const untracked = await this.runner.run(root, "git", [ + "ls-files", + "--others", + "--exclude-standard", + "-z", + ]); + if (untracked.code !== 0) { + throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`); + } + const additions: string[] = []; + for (const relative of untracked.stdout.split("\0").filter(Boolean)) { + const target = path.resolve(root, relative); + if (!isWithin(root, target)) { + throw new WorkspaceSafetyError(`Untracked path escapes worktree: ${relative}`); + } + const details = await lstat(target); + if (details.isSymbolicLink()) { + additions.push(`## Untracked symlink ${relative}\n${await readlink(target)}`); + } else if (details.isFile()) { + additions.push( + `## Untracked file ${relative}\n${(await readFile(target)).toString("utf8")}`, + ); + } + } + const completeDiff = `${diff.stdout}\n${additions.join("\n\n")}`; + if (Buffer.byteLength(completeDiff, "utf8") > 4 * 1024 * 1024) { + throw new WorkspaceSafetyError( + "Review diff exceeds the 4 MiB audited limit and cannot be reviewed completely", + ); + } + return completeDiff; + } +} + +export interface WorkspacePlan { + sourceRoot: string; + worktreeRoot: string; + worktreePath: string; + branch: string; + baseHead: string; + branchExists: boolean; +} + +export interface PrepareWorkspaceOptions { + sourceRoot: string; + configuredRoot: string; + featureName: string; + runId: string; +} + +export class WorkspaceValidationError extends Error { + readonly code: + | "not-repository" + | "dirty-source" + | "unignored-root" + | "existing-worktree-path" + | "git-failed"; + + constructor(code: WorkspaceValidationError["code"], message: string) { + super(message); + this.name = "WorkspaceValidationError"; + this.code = code; + } +} + +export class ExecFileCommandRunner implements CommandRunner { + async run(cwd: string, executable: string, args: string[]) { + try { + const result = await execFileAsync(executable, args, { + cwd, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + }); + return { code: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const failed = error as Error & { + code?: number | string; + stdout?: string; + stderr?: string; + }; + return { + code: typeof failed.code === "number" ? failed.code : 1, + stdout: failed.stdout ?? "", + stderr: failed.stderr ?? failed.message, + }; + } + } +} + +export class GitWorkspaceManager { + private readonly runner: CommandRunner; + + constructor(runner: CommandRunner = new ExecFileCommandRunner()) { + this.runner = runner; + } + + async prepare(options: PrepareWorkspaceOptions): Promise { + const requestedRoot = path.resolve(options.sourceRoot); + const repository = await this.git(requestedRoot, [ + "rev-parse", + "--show-toplevel", + ]); + if (repository.code !== 0) { + throw new WorkspaceValidationError( + "not-repository", + `Unable to resolve Git repository: ${repository.stderr.trim()}`, + ); + } + const sourceRoot = await realpath(path.resolve(repository.stdout.trim())); + if (sourceRoot !== (await realpath(requestedRoot))) { + throw new WorkspaceValidationError( + "not-repository", + `Workflow source must be the repository root ${sourceRoot}`, + ); + } + + const status = await this.git(sourceRoot, [ + "status", + "--porcelain", + "--untracked-files=normal", + ]); + if (status.code !== 0) { + throw new WorkspaceValidationError( + "git-failed", + `Unable to inspect source state: ${status.stderr.trim()}`, + ); + } + if (status.stdout.trim().length > 0) { + throw new WorkspaceValidationError( + "dirty-source", + "Source checkout must be clean before creating a workflow worktree", + ); + } + + const worktreeRoot = await this.containedWorktreeRoot( + sourceRoot, + options.configuredRoot, + ); + const relativeRoot = path + .relative(sourceRoot, worktreeRoot) + .replaceAll("\\", "/"); + const ignored = await this.git(sourceRoot, [ + "check-ignore", + "--quiet", + "--no-index", + "--", + `${relativeRoot}/.pi-workflow-ignore-check`, + ]); + if (ignored.code !== 0) { + throw new WorkspaceValidationError( + "unignored-root", + `Configured worktree root ${relativeRoot} must already be ignored by Git`, + ); + } + const worktreePath = path.join( + worktreeRoot, + `pi-workflow-${options.featureName}-${options.runId}`, + ); + try { + await lstat(worktreePath); + throw new WorkspaceValidationError( + "existing-worktree-path", + `Worktree path already exists: ${worktreePath}`, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const branch = `feature/${options.featureName}`; + const branchCheck = await this.git(sourceRoot, [ + "show-ref", + "--verify", + "--quiet", + `refs/heads/${branch}`, + ]); + if (branchCheck.code !== 0 && branchCheck.code !== 1) { + throw new WorkspaceValidationError( + "git-failed", + `Unable to inspect branch ${branch}: ${branchCheck.stderr.trim()}`, + ); + } + const head = await this.git(sourceRoot, ["rev-parse", "HEAD"]); + if (head.code !== 0) { + throw new WorkspaceValidationError( + "git-failed", + `Unable to resolve HEAD: ${head.stderr.trim()}`, + ); + } + + return { + sourceRoot, + worktreeRoot, + worktreePath, + branch, + baseHead: head.stdout.trim(), + branchExists: branchCheck.code === 0, + }; + } + + async create(plan: WorkspacePlan): Promise { + const branch = await this.git(plan.sourceRoot, [ + "show-ref", + "--verify", + "--quiet", + `refs/heads/${plan.branch}`, + ]); + if (branch.code !== 0 && branch.code !== 1) { + return { + state: "creating", + sourceRoot: plan.sourceRoot, + root: plan.worktreePath, + branch: plan.branch, + baseHead: plan.baseHead, + error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`, + }; + } + let addArguments = [ + "worktree", + "add", + "-b", + plan.branch, + plan.worktreePath, + plan.baseHead, + ]; + if (branch.code === 0) { + const branchHead = await this.git(plan.sourceRoot, [ + "rev-parse", + `refs/heads/${plan.branch}`, + ]); + if (branchHead.code !== 0 || branchHead.stdout.trim() !== plan.baseHead) { + return { + state: "failed", + sourceRoot: plan.sourceRoot, + root: plan.worktreePath, + branch: plan.branch, + baseHead: plan.baseHead, + error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`, + }; + } + addArguments = ["worktree", "add", plan.worktreePath, plan.branch]; + } + const result = await this.git(plan.sourceRoot, addArguments); + if (result.code !== 0) { + return this.reconcile({ + state: "creating", + sourceRoot: plan.sourceRoot, + root: plan.worktreePath, + branch: plan.branch, + baseHead: plan.baseHead, + error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`, + }); + } + return { + state: "ready", + sourceRoot: plan.sourceRoot, + root: plan.worktreePath, + branch: plan.branch, + baseHead: plan.baseHead, + }; + } + + async reconcile( + workspace: WorkflowWorkspace, + expectedWorktreeIdentity = workspace.root, + ): Promise { + if ( + workspace.state !== "creating" || + !workspace.root || + !workspace.branch + ) { + return workspace; + } + const listed = await this.git(workspace.sourceRoot, [ + "worktree", + "list", + "--porcelain", + ]); + if (listed.code !== 0) { + return { + ...workspace, + state: "failed", + error: `Unable to reconcile worktree: ${listed.stderr.trim()}`, + }; + } + const expectedRoot = path.resolve(workspace.root); + if ( + !expectedWorktreeIdentity || + path.resolve(expectedWorktreeIdentity) !== expectedRoot + ) { + return { + ...workspace, + state: "failed", + error: "Persisted worktree lease identity does not match its workspace", + }; + } + if (!workspace.baseHead) { + return { + ...workspace, + state: "failed", + error: "Persisted worktree base HEAD is missing", + }; + } + const blocks = listed.stdout + .split(/\n\n+/) + .map((block) => block.split("\n")); + const expectedBranch = `branch refs/heads/${workspace.branch}`; + const matchingPath = blocks.find((lines) => + lines.some( + (line) => + line.startsWith("worktree ") && + path.resolve(line.slice("worktree ".length)) === expectedRoot, + ), + ); + const matchingBranch = blocks.find((lines) => + lines.includes(expectedBranch), + ); + if (!matchingPath && !matchingBranch) { + return { ...workspace, state: "creating" }; + } + const expectedHead = `HEAD ${workspace.baseHead}`; + if ( + matchingPath === matchingBranch && + matchingPath?.includes(expectedHead) + ) { + return { ...workspace, state: "ready", error: undefined }; + } + return { + ...workspace, + state: "failed", + error: + "Interrupted worktree creation conflicts with the persisted path, branch, or base HEAD", + }; + } + + private async containedWorktreeRoot( + sourceRoot: string, + configuredRoot: string, + ) { + const requested = path.resolve(sourceRoot, configuredRoot); + const relative = path.relative(sourceRoot, requested); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new WorkspaceValidationError( + "unignored-root", + "Configured worktree root must be a directory inside the repository", + ); + } + let current = sourceRoot; + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + let info; + try { + info = await lstat(current); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + if (info.isSymbolicLink()) { + throw new WorkspaceValidationError( + "unignored-root", + `Configured worktree root cannot traverse symlink ${current}`, + ); + } + } + const canonicalParent = await realpath(path.dirname(current)); + const canonicalRelative = path.relative(sourceRoot, canonicalParent); + if ( + canonicalRelative.startsWith("..") || + path.isAbsolute(canonicalRelative) + ) { + throw new WorkspaceValidationError( + "unignored-root", + "Configured worktree root resolves outside the repository", + ); + } + return requested; + } + + private git(cwd: string, args: string[]) { + return this.runner.run(cwd, "git", args); + } +} diff --git a/extensions/shared/child-session.test.ts b/extensions/shared/child-session.test.ts index ebef45a..abad82e 100644 --- a/extensions/shared/child-session.test.ts +++ b/extensions/shared/child-session.test.ts @@ -20,6 +20,7 @@ import { createChildResources, resolveStandaloneChildProjectTrust, shutdownAndDisposeChildSession, + waitForChildOperation, type DisposableChildSession, } from "./child-session.ts"; @@ -105,6 +106,7 @@ test("child denylist keeps extension and workflow structured tools available", a "subagent_check", "subagent_list", "workflow", + "pi_workflow", "ask_user", ], ); @@ -253,6 +255,12 @@ test("shutdown helper balances hooks and disposal despite errors", async () => { assert.equal(disposals, 1); }); +test("child operations are bounded independently of teardown", async () => { + const startedAt = Date.now(); + await waitForChildOperation(new Promise(() => {}), 5); + assert.ok(Date.now() - startedAt < 100); +}); + test("shutdown helper bounds a stuck hook before disposal", async () => { let disposals = 0; const session: DisposableChildSession = { diff --git a/extensions/shared/child-session.ts b/extensions/shared/child-session.ts index daeb29d..aab3296 100644 --- a/extensions/shared/child-session.ts +++ b/extensions/shared/child-session.ts @@ -18,6 +18,7 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [ "subagent_check", "subagent_list", "workflow", + "pi_workflow", "ask_user", ] as const; @@ -92,7 +93,10 @@ export interface DisposableChildSession { const childShutdowns = new WeakMap>(); -function waitBounded(operation: Promise, timeoutMs: number) { +export function waitForChildOperation( + operation: Promise, + timeoutMs = CHILD_SHUTDOWN_TIMEOUT_MS, +) { let timer: ReturnType | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); @@ -124,7 +128,7 @@ export function shutdownAndDisposeChildSession( const shutdown = (async () => { try { if (session.extensionRunner.hasHandlers("session_shutdown")) { - await waitBounded( + await waitForChildOperation( session.extensionRunner.emit({ type: "session_shutdown", reason: "quit", diff --git a/extensions/subagents/src/backends/pi.ts b/extensions/subagents/src/backends/pi.ts index 0966014..dba4857 100644 --- a/extensions/subagents/src/backends/pi.ts +++ b/extensions/subagents/src/backends/pi.ts @@ -19,10 +19,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import { createAgentSession, - DefaultResourceLoader, - getAgentDir, SessionManager, - SettingsManager, } from "@earendil-works/pi-coding-agent"; import type { Cause, Scope } from "effect"; import { Effect, Queue, Stream } from "effect"; @@ -34,21 +31,15 @@ import type { TranscriptPart, } from "../domain.ts"; import { SendError, SpawnError } from "../domain.ts"; +import { + bindChildSessionExtensions, + childToolPolicy, + createChildResources, + shutdownAndDisposeChildSession, + waitForChildOperation, +} from "../../../shared/child-session.ts"; import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts"; -const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000; - -/** Tools that headless children must not receive. Everything else stays enabled. */ -const CHILD_EXCLUDED_TOOL_NAMES = [ - "subagent_spawn", - "subagent_wait", - "subagent_cancel", - "subagent_check", - "subagent_list", - "workflow", - "ask_user", -] as const; - // --- Model + effort resolution ----------------------------------------------- type ThinkingLevel = NonNullable< @@ -92,60 +83,6 @@ function resolvePiModel( throw new Error(`Unknown model "${hint}".`); } -// --- Child session helpers (ported from v1 shared/child-session.ts) ----------- - -/** Load normal global/package resources and trust-gated project resources. */ -async function createChildResources(cwd: string, projectTrusted: boolean) { - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir, { - projectTrusted, - }); - const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); - await loader.reload(); - return { loader, settingsManager }; -} - -function waitBounded(operation: Promise, timeoutMs: number) { - let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs); - }); - return Promise.race([ - operation.then( - () => undefined, - () => undefined, - ), - timeout, - ]) - .catch(() => {}) - .finally(() => { - if (timer) clearTimeout(timer); - }); -} - -/** Emit child session_shutdown (bounded), then dispose. Never throws. */ -async function shutdownAndDisposeChildSession(session: AgentSession) { - try { - if (session.extensionRunner.hasHandlers("session_shutdown")) { - await waitBounded( - session.extensionRunner.emit({ - type: "session_shutdown", - reason: "quit", - }), - CHILD_SHUTDOWN_TIMEOUT_MS, - ); - } - } catch { - // Extension runner inspection/emission is best-effort during teardown. - } finally { - try { - session.dispose(); - } catch { - // Disposal is terminal and must remain idempotent for callers. - } - } -} - // --- Event translation ---------------------------------------------------------- function messageRole(msg: unknown): Message["role"] | undefined { @@ -281,10 +218,10 @@ const makePiSession = ( const session = yield* Effect.tryPromise({ try: async () => { - const { loader, settingsManager } = await createChildResources( - task.cwd, - task.parent.projectTrusted, - ); + const { loader, settingsManager } = await createChildResources({ + cwd: task.cwd, + projectTrusted: task.parent.projectTrusted, + }); const { session } = await createAgentSession({ cwd: task.cwd, sessionManager: SessionManager.create(task.cwd), @@ -292,13 +229,13 @@ const makePiSession = ( resourceLoader: loader, model, thinkingLevel, - excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES], + ...childToolPolicy(), }); // Start child extension session hooks/resources in headless mode. // A rejection here would otherwise leak the freshly created session: // the scope finalizer that owns cleanup is only registered later. try { - await session.bindExtensions({ mode: "print" }); + await bindChildSessionExtensions(session); } catch (error) { await shutdownAndDisposeChildSession(session); throw error; @@ -493,7 +430,7 @@ const makePiSession = ( } catch { // Continue with abort/dispose. } - await waitBounded(session.abort(), CHILD_SHUTDOWN_TIMEOUT_MS); + await waitForChildOperation(session.abort()); await shutdownAndDisposeChildSession(session); Queue.endUnsafe(events); }), diff --git a/package.json b/package.json index a529269..2158b95 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,16 @@ "scripts": { "prepare": "effect-tsgo patch", "typecheck": "tsc --noEmit", - "test": "npm run test:runner && npm run test:links && node --test --experimental-strip-types extensions/firecrawl-search/index.test.ts extensions/shared/*.test.ts extensions/subagents/by-the-way.test.ts extensions/subagents/context-usage.test.ts extensions/subagents/manager.test.ts extensions/subagents/result-delivery.test.ts extensions/subagents/takeover.test.ts extensions/_workflows/*.test.ts && vitest run extensions/file-search/index.spec.ts", + "test": "npm run test:runner && npm run test:links && node --test --experimental-strip-types extensions/firecrawl-search/index.test.ts extensions/shared/*.test.ts extensions/subagents/by-the-way.test.ts extensions/subagents/context-usage.test.ts extensions/subagents/manager.test.ts extensions/subagents/result-delivery.test.ts extensions/subagents/takeover.test.ts extensions/_workflows/*.test.ts extensions/pi-workflow/*.test.ts && vitest run extensions/file-search/index.spec.ts", "test:runner": "python -m unittest discover -s scripts -p 'test_*.py'", "test:links": "node --test scripts/pi-links.test.mjs", "test:live": "node --test --experimental-strip-types extensions/subagents/claude.test.ts extensions/subagents/codex.test.ts" }, "pi": { "extensions": [ - "./extensions" + "./extensions", + "!./extensions/_workflows", + "!./extensions/pi-workflow" ], "skills": [ "./skills"