add persisted pi workflow extension

This commit is contained in:
jb
2026-08-25 16:07:44 -04:00
parent bfb07282d6
commit c5c3316e49
46 changed files with 20642 additions and 94 deletions
+1
View File
@@ -23,6 +23,7 @@
"extensions": {
"globalSymlinks": {
"effort-controls": "extensions/effort-controls",
"pi-workflow": "extensions/pi-workflow",
"update": "extensions/update"
},
"globalLocalPackages": {}
@@ -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/<feature-name>`, 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.
+68
View File
@@ -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/<feature-name>` 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 <run-id>
```
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/<run-id>/
```
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`.
+141
View File
@@ -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.
+679
View File
@@ -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<string, string>;
}
| {
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 <run-id>` 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 <run-id>` 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 <run-id>` 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
<repository>/.worktrees/pi-workflow-<feature-name>-<run-id>/
```
The corresponding branch is:
```text
feature/<feature-name>
```
`<feature-name>` 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/<run-id>/
```
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/<feature-name>`, 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 &amp; 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.
+69 -13
View File
@@ -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<any, any, any>[];
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<AgentOutcome> {
let structured: unknown;
let customTools: ToolDefinition[] | undefined;
let customTools: ToolDefinition<any, any, any>[];
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<void> | undefined;
let resolveCancellation = () => {};
const cancellation = new Promise<void>((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,
};
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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": <exact route>, "result": "passed", "viewport": { "width": <number>, "height": <number> }, "screenshotPath": <repository-relative path> }`. Return the repository-relative JSON report paths in `browserEvidence`; self-reported prose is not evidence.
+178
View File
@@ -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<void>) {
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/,
);
});
});
+256
View File
@@ -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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertKnownKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
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<WorkflowConfig> {
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;
}
@@ -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<void>) {
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<void>;
type ShutdownHandler = (
event: { reason: string; targetSessionFile?: string },
ctx: ExtensionContext,
) => Promise<void>;
type StartHandler = (
event: { reason: string; previousSessionFile?: string },
ctx: ExtensionContext,
) => Promise<void>;
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<ExtensionCommandContext["compact"]>[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<void>;
sendUserMessage(message: string): Promise<void>;
};
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",
],
);
});
});
@@ -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<ExtensionCommandContext["newSession"]>[0]
>;
type ReplacementContext = Parameters<
NonNullable<NewSessionOptions["withSession"]>
>[0];
export interface ContextTransitionRuntime {
store: WorkflowStore;
engineForContext(ctx: ExtensionContext): Promise<WorkflowEngine> | 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 <run-id>", "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<ExtensionAPI, "getActiveTools">,
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;
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isValidContextContinuation } from "./continuation.ts";
function state(overrides: Record<string, unknown> = {}) {
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,
);
});
+65
View File
@@ -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")
);
}
+384
View File
@@ -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> = {}): 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" });
});
+557
View File
@@ -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<ExtensionAPI, "getActiveTools">,
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<string, string> = {};
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<DashboardResult> {
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<string | null>(
(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) : {};
}
+465
View File
@@ -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<string, string> }
| {
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<string, string>;
pathDigests: Record<string, string>;
}
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",
},
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+862
View File
@@ -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<void>) {
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<void>((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<never>((_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<WorkflowRun["workspace"]> {
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"],
);
});
});
+817
View File
@@ -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<string, number>();
private readonly exclusiveRuns = new Set<string>();
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<T>(options: {
runId: string;
runLimit: number;
signal: AbortSignal;
exclusive?: boolean;
onReserved?: () => Promise<void>;
task: (signal: AbortSignal) => Promise<T>;
}): Promise<T> {
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<T>((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<Api> | 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<AgentOutcome>;
createResources?: (options: {
cwd: string;
projectTrusted: boolean;
appendSystemPrompt: string[];
}) => Promise<ChildResources>;
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<AssignmentExecutionResult> {
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,
};
},
});
}
}
+39
View File
@@ -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<void>)
| 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);
});
+123
View File
@@ -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<void>;
}
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);
}
@@ -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<void>) {
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<string, unknown>;
entries.push({
type: "message",
message: {
role: "custom",
customType: record.customType,
details: record.details,
},
});
}
},
} as unknown as Pick<ExtensionAPI, "sendMessage">;
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<string, unknown>).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<string, unknown>;
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<string, unknown>).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,
});
});
});
+207
View File
@@ -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<string>();
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type !== "message") continue;
const message = entry.message as unknown as Record<string, unknown>;
if (message.role !== "custom" || message.customType !== NOTIFICATION_TYPE) {
continue;
}
const details = message.details;
if (!details || typeof details !== "object") continue;
const key = (details as Record<string, unknown>).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<ExtensionAPI, "sendMessage">,
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;
+494
View File
@@ -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<void>) {
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<typeof profileDocument>[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<WorkflowFacts, "stage"> = {
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"],
);
});
+663
View File
@@ -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<string, unknown>;
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<string, unknown> {
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<string, unknown> = {};
let section: Record<string, unknown> | undefined;
let sectionName: string | undefined;
let skippedUnknownIndent: number | undefined;
let pendingList:
| { container: Record<string, unknown>; 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<string, unknown>;
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] = "<ignored nested value>";
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<T extends string>(
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<LoadSourceResult> {
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<string>();
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<ProfileCatalog> {
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<WorkflowFacts, "stage">,
) {
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 };
}
+280
View File
@@ -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);
}
}
@@ -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<Parameters<typeof runScriptSandbox>[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<void>((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<void>((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/,
);
});
+339
View File
@@ -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<ScriptAssignmentResult>;
onStage: (title: string) => void;
}
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<number>();
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<unknown>((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<number, AbortController>();
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<string, unknown>;
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<typeof parseScriptAssignmentPayload>;
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);
},
);
});
}
+512
View File
@@ -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<void>) {
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<string, unknown>;
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<string, unknown>;
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<void>((resolve) => {
markMiss = resolve;
});
const wait = new Promise<void>((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"],
);
});
});
+924
View File
@@ -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<void>;
afterRunLoadMiss?: (runId: string) => void | Promise<void>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<AssignmentLeaseRecord | undefined> {
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<WorkflowRun> {
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<T>(
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 });
}
}
+296
View File
@@ -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> = {}): 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<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
let finishDecision = (_view: WorkflowView) => {};
const decision = new Promise<WorkflowView>((resolve) => {
finishDecision = resolve;
});
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[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<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[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"],
);
});
+496
View File
@@ -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<typeof PiWorkflowParameters>;
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<string, number>;
}
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<string, unknown>)
.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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, number> = {};
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<ReturnType<ContextTransitionRuntime["engineForContext"]>>,
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<never>((_resolve, reject) => {
rejectCancellation = reject;
});
let cancellationWork: Promise<void> | 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,
};
},
});
}
+90
View File
@@ -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> = {}): 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");
});
+93
View File
@@ -0,0 +1,93 @@
import type { UiImpact, WorkflowFacts } from "./domain.ts";
const IMPACT_RANK: Record<UiImpact, number> = {
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)],
};
}
+671
View File
@@ -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<void>) {
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"), "<main>Prototype</main>", "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");
});
});
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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 = {
+6 -2
View File
@@ -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<object, Promise<void>>();
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
export function waitForChildOperation(
operation: Promise<unknown>,
timeoutMs = CHILD_SHUTDOWN_TIMEOUT_MS,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((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",
+14 -77
View File
@@ -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<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((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);
}),
+4 -2
View File
@@ -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"