From 5086d7e689e4d0f537960d119aaaff2918870616 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 6 Jun 2026 23:51:14 -0400 Subject: [PATCH 001/295] docs: add workflow boards design specs (v1/v2/v3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add phased design specs for project boards that act as state machines — swim lanes with multi-agent step pipelines, worktree-per-ticket isolation, and explicit waiting-on-user handling. v1 covers the rigorous core (durable saga with provider-dispatch outbox, worktree lease, ticket-level diff, durable-vs-volatile approval waits, version snapshots); v2 outlines script steps, predicates, per-step routing, WIP enforcement, and a visual editor; v3 sketches external events, PM-tool sync, multiple boards, and DAG lanes. Specs informed by two rounds of code-grounded review. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-06-workflow-boards-v1-design.md | 219 ++++++++++++++++++ .../2026-06-06-workflow-boards-v2-design.md | 124 ++++++++++ .../2026-06-06-workflow-boards-v3-design.md | 45 ++++ 3 files changed, 388 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md create mode 100644 docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md create mode 100644 docs/superpowers/specs/2026-06-06-workflow-boards-v3-design.md diff --git a/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md b/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md new file mode 100644 index 00000000000..3a1e433600f --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md @@ -0,0 +1,219 @@ +# Workflow Boards — Phase 1 (v1) Design + +- **Status:** Draft for review (revised after second code-grounded review) +- **Date:** 2026-06-06 +- **Author:** Chris + Claude (brainstorming session) +- **Related:** [v2 design](./2026-06-06-workflow-boards-v2-design.md), [v3 design](./2026-06-06-workflow-boards-v3-design.md) + +## 1. Overview + +Add project **boards** to t3code that act as **state machines**. A board has user-defined **swim lanes**; each lane can run a **pipeline of steps**, and each step is executed by a chosen **agent** (provider instance + model) or is a **human approval gate**. **Tickets** flow through the lanes; each ticket works in its own **git worktree** so an agent can implement, and work accumulates as the ticket moves down the board. Tickets that need human input show a **"waiting on you"** indicator; clicking a ticket reveals what the agent is doing, any questions it asked, and lets the user answer inline. + +This is inspired by "Damage Control" (an Asana board driving agents per ticket with copy-on-write isolation and explicit "waiting on owner" markers), made native to t3code and reusing its existing orchestration, ACP provider drivers, worktrees, approvals, and real-time streaming. + +### Design philosophy for v1 + +Narrow surface, deep correctness. v1 ships a small board that is genuinely safe under crashes, races, and concurrent edits. The broad configuration power (predicates, script gates, external integrations, visual editor) lands in [v2](./2026-06-06-workflow-boards-v2-design.md) and [v3](./2026-06-06-workflow-boards-v3-design.md) on top of these foundations. + +## 2. Locked decisions + +1. **Authoring model:** hybrid. A declarative **JSON workflow file** in the project repo is the source of truth (matches t3code's settings/contracts conventions, validated by an Effect Schema). A visual editor is deferred to v2; v1 hand-edits the file with validation surfaced in the UI. +2. **Lane logic = pipeline of steps.** Each step is bound to its own agent (provider instance + model) + instruction, or is an approval gate. +3. **Lane entry behavior is configurable per lane:** `auto` (run pipeline on entry) or `manual` (hold the ticket). +4. **Transitions (v1 subset):** auto-routing on pipeline result (lane-level `on.success`/`failure`/`blocked`), and manual drag-and-drop. Conditional predicates and external events are deferred. +5. **Isolation:** one git worktree per ticket, shared across all its lanes/steps, guarded by a single-writer lease (§7.1). +6. **Ticket sources:** manual creation only in v1. PM-tool sync deferred to v3. +7. **Integration:** a new bounded context ("workflow/board engine") with **its own event tables and projections** (not an extension of `orchestration_events`; see §5.3). It is a durable **saga / process-manager**, not a thin event reactor. +8. **Step types in v1:** `agent` and `approval` only. Script steps deferred to v2. + +## 3. Reused t3code primitives — and where they need new seams + +| Need | Existing primitive | Gap to build in v1 | +| --- | --- | --- | +| Run an agent on a turn | Orchestration engine (event-sourced threads/turns) + ACP provider drivers (Claude, Codex, Cursor, OpenCode, Grok) via `OrchestrationEngine.dispatch` (`thread.create`, `thread.turn.start`) | A **durable provider-dispatch path** — see §7.8. `ProviderCommandReactor` consumes a *hot* domain-event stream with no replay, so a crash between command-accept and provider-turn-send is not self-healing. | +| Per-ticket repo copy | VCS worktree layer (`vcs.createWorktree`/`removeWorktree`, `GitWorkflowService`, `GitManager`) | A **`WorktreeLeaseService`** (§7.1) — no fencing exists today; git ops mutate by `cwd`. And setup-script invocation is **not** part of `createWorktree` (§7.2). | +| Agent asked a question / needs approval | Pending-approval projection (`projection_pending_approvals`) for approvals; user *questions* are activity rows | **Durable workflow approval gates** distinct from **volatile provider questions/approvals**, which do not survive restart (§7.3, §7.9). | +| Per-turn diffs | Checkpoint system (`CheckpointStore`, `CheckpointDiffQuery`), refs keyed `refs/t3/checkpoints//turn/` | **Ticket-level baseline ref + accumulated diff** query — net-new; thread-turn diffs only give one step's delta (§7.10). | +| Real-time UI | WebSocket RPC subscriptions over `orchestrationEngine.streamDomainEvents` (per `ws.ts`); React 19 + TanStack Router + Zustand/Effect atoms + dnd-kit | A parallel **`workflow.streamBoardEvents`** subscription for board projections. | +| Provider routing | `ProviderInstanceId` + `ModelSelection` | None — reused as-is. | + +**Critical reuse constraint:** the provider command reactor restricts switching provider instances inside an active session. Each step therefore runs as its **own thread** (one thread per step), all sharing the ticket's worktree as cwd. Do **not** reuse a single thread across steps with different providers. + +## 4. Scope + +**In scope (v1):** manual tickets; one board per project; JSON workflow file (loader + Effect Schema validation + config linter); lanes with `auto`/`manual` entry; `agent` and `approval` steps; lane-level routing; manual drag (with lane-entry tokens); worktree-per-ticket with a single-writer lease; ticket baseline + per-step checkpoints and accumulated ticket diff; per-run workflow-version snapshots; durable saga (provider-dispatch outbox, deterministic IDs, crash recovery); board UI + ticket drill-in + waiting-on-user with inline answering; configurable max concurrent running tickets per board. + +**Out of scope (deferred):** script steps, conditional predicates, per-step routing, WIP *enforcement* (v1 displays counts only), external events, PM-tool sync, multiple boards per project, DAG lanes, the visual workflow editor, and in-flight `pause` (v1 supports `abort` only — see §8). + +## 5. Data model + +A new bounded context with its own SQLite tables and projections. + +### 5.1 Persistent entities (projections) + +- **Board** — `{ id, projectId, name, workflowFilePath, workflowVersionHash, settings: { maxConcurrentTickets } }`. +- **Ticket** — `{ id, boardId, title, description, currentLaneKey, status, worktreeRef, baselineCheckpointId, labels[], priority, source: "manual", externalRef: string | null, createdAt, updatedAt }`. + - `externalRef` is nullable and unused in v1 (forward-compat for v3 PM sync). + - `status ∈ { idle, running, waiting_on_user, blocked, done, failed }`. +- **PipelineRun** — `{ id, ticketId, laneKey, laneEntryToken, status, workflowSnapshot, startedAt, finishedAt, result }`. +- **StepRun** — `{ id, pipelineRunId, stepKey, type, execRef, status, result, waitingReason, preCheckpointRef, postCheckpointRef, startedAt, finishedAt }`. + - `execRef` union: `{ type: "agent", threadId, dispatchId }` | `{ type: "approval", approvalRequestId }`. (v2 adds `{ type: "script", scriptRunId }`.) + - `status ∈ { pending, dispatch_requested, running, awaiting_user, completed, failed, superseded }`. + +Lanes and Steps are **not** DB rows — they live in the workflow file and are snapshotted into `PipelineRun.workflowSnapshot` at run start (§7.4). + +### 5.2 Workflow events (new table namespace) + +`workflow_events` (separate from `orchestration_events`). Event types: `TicketCreated`, `TicketMovedToLane` (carries a fresh `laneEntryToken`), `PipelineStarted`, `ProviderDispatchRequested` (outbox; carries deterministic `dispatchId`/`threadId`/`messageId`/`commandId`), `ProviderDispatchConfirmed`, `StepStarted`, `StepAwaitingUser`, `StepUserResolved`, `StepCompleted`, `StepFailed`, `PipelineCompleted`, `TicketRouted`, `TicketBlocked`, `TicketSuperseded`, `TicketAbortRequested`, `WorktreeLeaseAcquired`, `WorktreeLeaseReleased`. The board read model is a projection over these events. + +### 5.3 Persistence boundary (resolves review finding #5) + +`OrchestrationEventStore` is typed only for `OrchestrationEvent`, and the contracts restrict orchestration event/aggregate kinds to project/thread events. v1 therefore introduces **dedicated workflow tables** (`workflow_events`, `projection_board`, `projection_ticket`, `projection_pipeline_run`, `projection_step_run`, `workflow_dispatch_outbox`, `worktree_lease`) and a `WorkflowEventStore` mirroring the existing event-store pattern. The orchestration contracts/union are **not** widened. The workflow engine reads orchestration state through existing projection queries (e.g. turn terminal state, pending-approval projection) — it does not write orchestration events except via the public `OrchestrationEngine.dispatch` API. + +## 6. Workflow file format + +JSON with a `$schema`, validated by an Effect Schema, at `.t3/boards/.json`. Instructions may be inline strings or `{ "file": "" }`. + +```jsonc +{ + "$schema": "t3code://schemas/workflow-v1.json", + "name": "Standard delivery", + "settings": { "maxConcurrentTickets": 3 }, + "lanes": [ + { "key": "backlog", "name": "Backlog", "entry": "manual" }, + { "key": "implement", "name": "Implement", "entry": "auto", + "pipeline": [ + { "key": "code", "type": "agent", + "agent": { "instance": "claude_main", "model": "sonnet" }, + "instruction": { "file": "prompts/implement.md" } }, + { "key": "review", "type": "agent", + "agent": { "instance": "codex_main", "model": "gpt-5.4" }, + "instruction": "Adversarially review the diff; list blocking issues." } + ], + "on": { "success": "owner_review", "failure": "needs_attention" } }, + { "key": "owner_review", "name": "Owner Review", "entry": "manual" }, + { "key": "needs_attention", "name": "Needs Attention", "entry": "manual" }, + { "key": "done", "name": "Done", "entry": "manual", "terminal": true } + ] +} +``` + +### Config linter (load-time) +Rejects: duplicate lane/step keys; routing targets referencing missing lanes; `auto`-lane cycles with no human/terminal break; unreachable terminal lanes; unknown/unconfigured provider instances; missing instruction files. **Diagnostics:** the loader parses with a **location-aware JSONC parser** (not bare Effect Schema decode, which loses source positions) so lint errors carry file/line/column. Lint errors block board activation and surface in the UI. + +## 7. Engine behavior + +The **WorkflowEngine** is a durable saga/process-manager. It never trusts ephemeral streams for state decisions; it reconciles from persisted orchestration projections, checkpoints, and its own outbox. + +### 7.1 WorktreeLeaseService (resolves finding #2) + +A persistent fencing lease, one row per ticket worktree in `worktree_lease`: `{ worktreeRef, ownerKind, ownerId (stepRunId | "user"), fenceToken (monotonic), acquiredAt, expiresAt }`. + +- **Guarded operations:** starting an agent step's provider turn, capturing checkpoints, and any engine-initiated git mutation must present a valid current `fenceToken`. A stale token (e.g. from a superseded run) is rejected. +- **Acquire/release:** the engine acquires before an agent step's provider dispatch and releases on terminal step state. Approval steps hold no lease. +- **TTL/recovery:** leases have a TTL; on restart the engine reconciles — a lease whose owning StepRun is terminal or superseded is released; an still-running owner re-validates. +- **Manual drag / abort:** supersede the current run, which invalidates its fence token so any late completion cannot mutate the tree. +- **Scope (v1):** because v1 runs steps strictly sequentially per ticket and never lets the user edit the ticket worktree concurrently with a running step, the lease is a correctness guard against *stale/superseded* writers, not against intentional parallel writers (that arrives with v2 scripts / v3 DAGs). + +### 7.2 Agent step execution + +1. Ensure the ticket worktree exists. **Setup gate (resolves finding #6):** worktree creation (`vcs.createWorktree`) does **not** run project setup scripts; v1 explicitly invokes `ProjectSetupScriptRunner` and **waits for a durable completion signal** before the first agent step. Since the runner currently emits only "started", v1 adds a completion event/await (a small addition to the runner) — setup is a **gate**, not fire-and-forget. +2. Capture the **baseline checkpoint** at ticket creation if not present (§7.10). +3. Acquire the worktree lease (§7.1); capture a **pre-step checkpoint** (`preCheckpointRef`). +4. Dispatch the provider turn through the durable path in §7.8. +5. Mark the step terminal **only when both** hold, read from persisted state: (a) the projected turn state is terminal, and (b) the post-turn checkpoint/diff is finalized. A pending question/approval maps to `StepAwaitingUser` → ticket `waiting_on_user` (§7.3). +6. On terminal success, capture the **post-step checkpoint** (`postCheckpointRef`), release the lease, record `StepCompleted` with the run snapshot. + +### 7.3 Waiting-on-user: durable gates vs. volatile provider waits (resolves finding #4) + +Two distinct mechanisms, surfaced identically in the UI but handled differently: + +- **`approval` step (durable):** the workflow engine owns the request (`approvalRequestId` in `workflow_events`). It survives restart; the user's response is applied by the engine and routes the pipeline. No provider session involved. +- **Provider-generated question/approval (volatile):** an agent step where the agent itself asks a question or requests a tool approval. This depends on a live provider session callback, which **does not survive restart** (the provider command reactor records stale failures otherwise). v1 handles this explicitly: + - While the session is live, the user answers inline and the turn resumes normally. + - If the provider session is lost (restart/crash) while a step is `awaiting_user`, the engine does **not** attempt to resurrect the dead callback. It marks the StepRun for **idempotent re-dispatch**: the worktree already holds the agent's prior work (checkpointed), so the engine re-runs the step's turn with context that includes the prior diff and the user's answer, producing a fresh live session. The UI shows a "resumed after restart" note. + - This re-dispatch is the reason agent steps must be written to tolerate re-entry (the instruction templates say "continue from the current worktree state"). + +### 7.4 Workflow-version snapshots (immutability) + +At `PipelineStarted`, the engine resolves the workflow file and snapshots into `PipelineRun.workflowSnapshot`: lane key, ordered step keys, each step's resolved provider selection, instruction content (inline, or instruction-file content + hash), and routing rules. Later edits to the file or `.md` instructions never change in-flight or historical runs. + +### 7.5 Routing and lane-entry tokens + +Every `TicketMovedToLane` carries a fresh `laneEntryToken`; any pipeline it spawns is bound to that token. On pipeline completion the engine evaluates the lane-level `on` map **only if** the ticket is still on the same `laneEntryToken`; otherwise the completing pipeline is `superseded` and does not route. Routing to the next lane triggers that lane's entry behavior (chaining through `auto` lanes). + +### 7.6 Manual drag + +A drag emits `TicketMovedToLane` with a new token, supersedes any in-flight pipeline (invalidating its fence token), and triggers the target lane's entry behavior. + +### 7.7 Concurrency + +A board-level `maxConcurrentTickets` caps tickets in `running` state simultaneously. Excess auto-entries wait until a slot frees (engine-internal scheduling, distinct from v2 lane WIP limits). + +### 7.8 Durable provider dispatch (resolves finding #1) + +The engine must not rely on re-dispatching `thread.turn.start` for recovery, because the command is deduped while the provider side-effect (consumed off a hot stream) is not replayed. v1 introduces a **provider-dispatch outbox**: + +1. The engine writes a `ProviderDispatchRequested` row to `workflow_dispatch_outbox` with a deterministic `dispatchId` and the intended `{ threadId, messageId, commandId, providerInstance, model, instruction }`. +2. A dedicated **dispatch worker** (a durable, replayable reactor owned by the workflow engine) reads pending outbox rows and ensures the thread is created and the turn started via `OrchestrationEngine.dispatch`, keyed by `commandId` for idempotency. +3. The worker confirms by observing the **persisted** turn-start projection (not the hot stream) and writes `ProviderDispatchConfirmed`. Until confirmed, the row stays pending and is retried on a backoff. +4. On restart, the worker re-reads unconfirmed outbox rows and re-ensures — exactly-once in effect because creation/turn-start are idempotent on `commandId`/`threadId`, and confirmation is based on persisted state. + +This makes "the agent actually started" a durable, recoverable fact rather than an artifact of a live stream. + +### 7.9 Crash recovery (summary) + +On restart the engine rebuilds from `workflow_events` and reconciles: (a) unconfirmed outbox rows → re-ensure provider dispatch (§7.8); (b) running agent StepRuns → check persisted turn terminal state + checkpoint finalization to complete or continue; (c) `awaiting_user` agent steps with a lost session → mark for idempotent re-dispatch (§7.3); (d) leases → reconcile against owner StepRun state (§7.1). No duplicate threads result, because every dispatch is keyed by deterministic IDs and confirmed from persisted state. + +### 7.10 Ticket-level checkpoints & accumulated diff (resolves finding #3) + +Existing checkpoint refs are `threadId`+turn scoped, so a per-thread (per-step) diff is only one step's delta. v1 adds ticket-scoped checkpointing: + +- At ticket creation, record a **baseline ref** for the worktree (the base commit/tree the worktree starts from), stored as `Ticket.baselineCheckpointId`. +- Around each agent step, capture `preCheckpointRef`/`postCheckpointRef` (ticket-scoped refs, e.g. `refs/t3/tickets//step//{pre,post}`), reusing `CheckpointStore`'s underlying capture/diff plumbing but under a ticket namespace. +- The **accumulated ticket diff** shown in the drill-in is a **base-to-current** diff (baseline ref → current worktree tree), a new query alongside `CheckpointDiffQuery`. Per-step diffs use `preCheckpointRef → postCheckpointRef`. +- This is explicitly net-new server work in v1 (not just reuse). + +## 8. UI + +### Board view +Lanes as columns showing name, `auto`/`manual`, and a WIP count (display only). Tickets as drag-able cards (dnd-kit). Card badges: agent+model chip with running spinner; `⏸ waiting on you`; `⚠ blocked`/`failed`; `✓ done`. + +### Ticket drill-in +- **Step timeline:** each StepRun with agent, status, result. +- **Live agent activity:** reuse existing thread rendering for the active step's thread. +- **Question / approval inbox:** answer inline; answering resolves the gate (durable) or the live provider request (volatile) and resumes/​re-dispatches the step (§7.3). +- **Accumulated diff:** ticket-level base-to-current diff (§7.10). +- **Controls (v1):** `run` (start a manual lane's pipeline) and `abort`. **`abort`** is a durable transition: it supersedes the run, signals interrupt to the provider, waits for provider-terminal + checkpoint finalization, then releases the lease and marks the StepRun `failed`/ticket `blocked`. **`pause` is deferred** (no clean v1 semantics for releasing the lease mid-turn). + +### Workflow file errors +Parse/lint errors (§6) surface in the board UI via the existing settings file-watcher pattern (reload/debounce). No in-app editing in v1. + +## 9. Guarantees & failure modes + +- **The agent actually started** is durable and recoverable (provider-dispatch outbox, §7.8). +- **No duplicate work** on crash/retry (deterministic IDs + outbox + persisted confirmation). +- **No worktree races** from stale/superseded writers (fenced lease, §7.1). +- **Correct step completion** (turn terminal AND checkpoint finalized; never trust ephemeral streams). +- **History is immutable** under file edits (per-run snapshots, §7.4). +- **No stale routing** after a manual drag (lane-entry tokens, §7.5). +- **Accurate accumulated ticket diff** (ticket baseline + base-to-current query, §7.10). +- **Restart-safe waiting-on-user**: durable gates persist; volatile provider waits degrade to idempotent re-dispatch rather than silent stale failure (§7.3). + +## 10. Testing + +- Engine as a pure event-sourced reducer: transitions, routing precedence, token/lease logic, recovery from partial states with a fake clock and a stub orchestration engine. +- Dispatch-outbox idempotency: replay `ProviderDispatchRequested` across simulated crashes; assert exactly one thread and correct confirmation from persisted state. +- Lease fencing: superseded runs cannot mutate after a drag/abort. +- Ticket diff: baseline → current correctness across multi-step accumulation; per-step pre/post correctness. +- Config linter: table-driven cases incl. location-aware diagnostics. +- Integration: a 3-lane workflow driving a mock ACP provider through code → review → done, including waiting-on-user pause/resume, a crash-restart mid-step (agent re-dispatch), and an abort. +- Follows t3code's Effect/Vitest + deterministic test conventions. + +## 11. Open questions (genuinely open after revision) + +- Exact module homes (`apps/server/src/workflow/`, `packages/contracts/src/workflow.ts`) — confirm against current conventions during planning. +- Whether ticket worktrees live under the existing `apps/server/worktrees/` path or a board-scoped subdirectory. +- The minimal addition to `ProjectSetupScriptRunner` to emit a durable "setup completed" signal (§7.2) — confirm the cleanest event/await shape. +- Whether the ticket-scoped checkpoint refs reuse `CheckpointStore` directly under a new ref namespace or warrant a thin `TicketCheckpointStore` wrapper (§7.10). +- Exact RPC surface for `workflow.*` (board subscribe, ticket CRUD, answer/approve, drag, run/abort) mirroring the existing `orchestration.*` methods. diff --git a/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md b/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md new file mode 100644 index 00000000000..9df7c4b649f --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md @@ -0,0 +1,124 @@ +# Workflow Boards — Phase 2 (v2) Design + +- **Status:** Draft for review (depends on v1) +- **Date:** 2026-06-06 +- **Author:** Chris + Claude (brainstorming session) +- **Related:** [v1 design](./2026-06-06-workflow-boards-v1-design.md), [v3 design](./2026-06-06-workflow-boards-v3-design.md) + +## 1. Overview + +v2 adds the **configuration power** on top of v1's rigorous core: first-class script steps, structured agent outputs, conditional predicates, per-step routing, WIP enforcement, and a visual workflow editor. None of these require re-architecting the v1 engine — they extend the data model, the workflow schema, and the UI. Each section below states its dependency on v1 primitives and its new surface. + +## 2. Scope + +**In scope (v2):** +1. Script steps (first-class, durable, trust-gated). +2. Structured agent-step outputs (enabling sub-feature). +3. Conditional predicates (`when`) on transitions. +4. Per-step routing. +5. WIP-limit enforcement. +6. Visual workflow editor. + +**Out of scope (v3):** external events, PM-tool sync, multiple boards per project, DAG lanes. + +## 3. Script steps + +**Motivation:** a board that ships code is far more useful if pipelines can deterministically gate on tests/lint with clean pass/fail, logs, and cancellation. v1 deliberately deferred this because repo-owned shell commands are effectively local CI / arbitrary code execution. + +### Schema +New step `type: "script"` with **structured** config (no free-form shell string): + +```jsonc +{ "key": "tests", "type": "script", + "command": "pnpm", "args": ["test"], + "timeout": 600, "env": ["CI"], "maxOutputBytes": 1048576, + "shell": false, "workingDir": "." } +``` + +### Durable ScriptRun entity +A first-class `ScriptRun` with its own event stream: `ScriptRunStarted`, periodic `ScriptRunOutputChunk` (capped, persisted), `ScriptRunExited { exitCode }`. Output is live-streamed to the UI via the push bus, cancellable (kills the process group), and timeout-enforced. This replaces v1's reliance on a fire-and-forget `ProcessRunner.run`. + +### Trust model +A project must be **explicitly trusted** to run workflow scripts before any script step executes. Untrusted → the step parks as `blocked` with a "grant trust" prompt. Trust is keyed to the workflow file's content hash, so edits re-prompt. Trust state is persisted per project. + +### Integration with v1 +- `StepRun.execRef` gains `{ type: "script", scriptRunId }`. +- The script holds the ticket's worktree **lease** while running (it is a writer). +- Routing: exit 0 = success, non-zero = failure; `exitCode` and an output tail are exposed to routing and to predicates (§5). + +## 4. Structured agent-step outputs + +Agent steps may emit a defined, schema-validated output object alongside their diff — e.g. `{ "verdict": "block" | "pass", "notes": "…" }`. This gives predicates and routing a reliable typed signal instead of scraping prose. + +- The workflow step declares an `output` schema; the engine validates the agent's emitted output against it. +- Emitted outputs are stored on the `StepRun.result` and included in the predicate evaluation context (§5). +- Mechanism for an agent to emit the object (structured tool call vs. a sentinel block in the final message) is decided during v2 planning, favoring whatever the ACP layer supports cleanly. + +## 5. Conditional predicates (`when`) + +Transitions guarded by a **restricted, safe expression DSL** (not arbitrary JS) over a typed evaluation context. + +### Context +- Ticket fields: `label`, `priority`, `status`. +- Last pipeline result: `pipeline.result`. +- Structured step outputs: `steps..` (from §4), and `steps..exitCode` (from §3). + +### Example +```jsonc +{ "key": "review_gate", "name": "Review", "entry": "auto", + "pipeline": [ /* … */ ], + "transitions": [ + { "when": "steps.review.verdict == \"block\"", "to": "needs_attention" }, + { "when": "label == \"approved\"", "to": "done" } + ], + "on": { "success": "owner_review" } } +``` + +- Schema-validated and linted at load: unknown context fields rejected; type mismatches rejected. +- Evaluated deterministically at transition time; the matched predicate is recorded in the event log for auditability. +- Precedence with routing defined in §6. + +## 6. Per-step routing + +A step may declare its own `on: { success, failure, blocked }` that **short-circuits** the rest of the pipeline and routes the ticket immediately (e.g. review → "block" → jump straight to Needs Attention, skipping later steps). + +**Precedence (explicit, linted):** +1. Step-level `on` (if the step short-circuits). +2. Conditional `transitions` (`when`) evaluated at pipeline completion. +3. Lane-level `on`. +4. Manual drag always supersedes (carries over from v1's lane-entry tokens). + +The linter validates that step-level and lane-level routes don't reference missing lanes and that precedence is unambiguous. + +## 7. WIP-limit enforcement + +v1 displays WIP counts; v2 enforces them. A lane with `wipLimit: N`: +- **Block mode:** manual drags into a full lane are rejected with UI feedback; auto-routing holds the ticket in the prior lane (status reflects "waiting for WIP slot") until a slot frees. +- **Queue mode:** incoming tickets queue in order and admit as slots free. + +Mode is configurable per lane (`wip: { limit, mode: "block" | "queue" }`). Enforcement is engine-internal and distinct from v1's board-level `maxConcurrentTickets`. + +## 8. Visual workflow editor + +A GUI that makes the JSON workflow file editable without hand-editing, while keeping the file as source of truth (the "hybrid" promise). + +- **Read:** parse the workflow file, render lanes / pipelines / steps as editable forms (or a lightweight graph). +- **Edit:** step editor pulls provider instances + models from the configured ACP instances; instruction is an inline editor or a file-picked `.md` reference; routing and predicates editable with validation. +- **Write:** serialize back to canonical JSON in the repo file; show a diff preview before save. +- **Reconcile:** detect external edits via the existing settings file-watcher; warn on conflict and offer reload/merge. +- **Validate live:** run the v1 linter continuously; block save on errors. + +## 9. Testing + +- Script steps: durable-log capture, timeout/cancellation (process-group kill), trust-gate enforcement (untrusted parks as `blocked`), exit-code routing. +- Structured outputs: schema validation pass/fail; malformed output → step `failed`. +- Predicates: DSL evaluation table tests; unknown-field and type-mismatch rejection at lint time; recorded matched-predicate in events. +- Per-step routing precedence: table-driven precedence resolution. +- WIP enforcement: block vs queue modes under concurrent entries. +- Visual editor: round-trip fidelity (parse → edit → serialize → re-parse equals intended), external-edit reconciliation. + +## 10. Open questions + +- Exact agent-output emission mechanism over ACP (structured tool call vs. sentinel block). +- DSL choice for predicates (a minimal purpose-built grammar vs. an existing safe evaluator like JSONLogic). +- Whether the visual editor preserves JSONC comments or normalizes to canonical JSON on save. diff --git a/docs/superpowers/specs/2026-06-06-workflow-boards-v3-design.md b/docs/superpowers/specs/2026-06-06-workflow-boards-v3-design.md new file mode 100644 index 00000000000..0f87c7e32e6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-workflow-boards-v3-design.md @@ -0,0 +1,45 @@ +# Workflow Boards — Phase 3 (v3) Design + +- **Status:** Horizon outline (depends on v1 + v2) +- **Date:** 2026-06-06 +- **Author:** Chris + Claude (brainstorming session) +- **Related:** [v1 design](./2026-06-06-workflow-boards-v1-design.md), [v2 design](./2026-06-06-workflow-boards-v2-design.md) + +## 1. Overview + +v3 connects boards to the outside world and scales the model: external-event transitions, PM-tool sync as a pluggable ticket source, multiple boards per project, and DAG lanes. This is a horizon outline — each item gets its own detailed spec before implementation. It is documented now so v1/v2 foundations are built to accommodate it without rework. + +## 2. External-event transitions + +Let transitions fire on signals from outside the board — the Damage-Control pattern of "developer opened the PR → move to In Review." + +- **Sources:** PR opened / PR merged / CI passed, via t3code's existing GitHub source-control clients; generic inbound webhooks later. +- **Schema:** `transitions: [{ "when": "external.pr_opened", "to": "in_review" }]` (the `external.*` namespace extends the v2 predicate context). +- **Engine:** external events arrive as durable, idempotent inbox entries (deduplicated by source event ID) and are evaluated against the same token/routing machinery as v1/v2 so they cannot race a manual drag. +- **Design constraint inherited from v1:** all external signals must be reconciled from a durable inbox, never trusted as fire-and-forget callbacks. + +## 3. PM-tool sync (pluggable ticket source) + +Sync tickets from Linear / Asana / Jira (chosen during brainstorming over a GitHub-issue mirror — boards are PM boards, not issue mirrors). + +- **Source abstraction:** a `TicketSource` interface (pull external items → tickets; optionally push status back). Manual creation (v1) is the default source; PM tools are additional implementations. +- **Mapping:** external item ↔ ticket via `externalRef` (already on the v1 Ticket model); a configurable field/lane mapping per board. +- **Auth:** each integration is its own auth surface; credentials stored via the existing server settings/secret mechanism. +- **Conflict policy:** define source-of-truth precedence per field (e.g. title follows the PM tool; lane follows the board) and a reconciliation cadence. + +## 4. Multiple boards per project + +v1 assumes one board per project. v3 allows several (e.g. "delivery" and "triage" boards), each with its own workflow file under `.t3/boards/`. Requires board selection in the UI and per-board scoping of tickets and concurrency limits. + +## 5. DAG lanes (branching / parallel steps) + +Extend a lane's pipeline from a linear sequence to a graph: steps with conditional branches and parallel fan-out/fan-in (e.g. `implement → {tests pass? review : fix→loop} → parallel(docs, changelog) → done`). + +- Builds on v2's structured outputs and predicates for branch conditions. +- Parallel steps that mutate the worktree require either sub-worktrees or serialized writes under the v1 lease — a key design question for this phase. +- Likely the largest single addition; deferred until the linear model is proven in real use. + +## 6. Notes + +- v3 items are independent of one another and can ship in any order; each is purely additive on top of v1's saga/lease/checkpoint/token core and v2's predicate/output machinery. +- No v3 item should require changing v1's event schema in a breaking way; new event types and context namespaces are added, existing ones preserved. From db3cf88a72aa1ff4c69f5e13d3520aca08945a92 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 6 Jun 2026 23:58:54 -0400 Subject: [PATCH 002/295] docs: close final review gaps in workflow boards v1 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the three remaining must-fix items from the final code-grounded review pass: - §7.8: confirm provider dispatch from persisted provider PROGRESS (turn running/terminal), not command intake; specify workflow-owned replay since ProviderCommandReactor's hot stream does not replay. - §7.2.1: model setup as a durable SetupRun gated on terminal-exit, with timeout and restart reconciliation (idempotent setup scripts required). - §7.10: rename baselineCheckpointId -> baselineRef and split per-step ref-to-ref diff (reuse) from accumulated base-to-working-tree diff (new TicketDiffQuery). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-06-workflow-boards-v1-design.md | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md b/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md index 3a1e433600f..ec3dc10e96f 100644 --- a/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md +++ b/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md @@ -52,7 +52,7 @@ A new bounded context with its own SQLite tables and projections. ### 5.1 Persistent entities (projections) - **Board** — `{ id, projectId, name, workflowFilePath, workflowVersionHash, settings: { maxConcurrentTickets } }`. -- **Ticket** — `{ id, boardId, title, description, currentLaneKey, status, worktreeRef, baselineCheckpointId, labels[], priority, source: "manual", externalRef: string | null, createdAt, updatedAt }`. +- **Ticket** — `{ id, boardId, title, description, currentLaneKey, status, worktreeRef, baselineRef, labels[], priority, source: "manual", externalRef: string | null, createdAt, updatedAt }`. - `externalRef` is nullable and unused in v1 (forward-compat for v3 PM sync). - `status ∈ { idle, running, waiting_on_user, blocked, done, failed }`. - **PipelineRun** — `{ id, ticketId, laneKey, laneEntryToken, status, workflowSnapshot, startedAt, finishedAt, result }`. @@ -64,11 +64,11 @@ Lanes and Steps are **not** DB rows — they live in the workflow file and are s ### 5.2 Workflow events (new table namespace) -`workflow_events` (separate from `orchestration_events`). Event types: `TicketCreated`, `TicketMovedToLane` (carries a fresh `laneEntryToken`), `PipelineStarted`, `ProviderDispatchRequested` (outbox; carries deterministic `dispatchId`/`threadId`/`messageId`/`commandId`), `ProviderDispatchConfirmed`, `StepStarted`, `StepAwaitingUser`, `StepUserResolved`, `StepCompleted`, `StepFailed`, `PipelineCompleted`, `TicketRouted`, `TicketBlocked`, `TicketSuperseded`, `TicketAbortRequested`, `WorktreeLeaseAcquired`, `WorktreeLeaseReleased`. The board read model is a projection over these events. +`workflow_events` (separate from `orchestration_events`). Event types: `TicketCreated`, `TicketMovedToLane` (carries a fresh `laneEntryToken`), `SetupStarted`, `SetupCompleted`, `SetupFailed`, `PipelineStarted`, `ProviderDispatchRequested` (outbox; carries deterministic `dispatchId`/`threadId`/`messageId`/`commandId`), `ProviderDispatchConfirmed`, `StepStarted`, `StepAwaitingUser`, `StepUserResolved`, `StepCompleted`, `StepFailed`, `PipelineCompleted`, `TicketRouted`, `TicketBlocked`, `TicketSuperseded`, `TicketAbortRequested`, `WorktreeLeaseAcquired`, `WorktreeLeaseReleased`. The board read model is a projection over these events. ### 5.3 Persistence boundary (resolves review finding #5) -`OrchestrationEventStore` is typed only for `OrchestrationEvent`, and the contracts restrict orchestration event/aggregate kinds to project/thread events. v1 therefore introduces **dedicated workflow tables** (`workflow_events`, `projection_board`, `projection_ticket`, `projection_pipeline_run`, `projection_step_run`, `workflow_dispatch_outbox`, `worktree_lease`) and a `WorkflowEventStore` mirroring the existing event-store pattern. The orchestration contracts/union are **not** widened. The workflow engine reads orchestration state through existing projection queries (e.g. turn terminal state, pending-approval projection) — it does not write orchestration events except via the public `OrchestrationEngine.dispatch` API. +`OrchestrationEventStore` is typed only for `OrchestrationEvent`, and the contracts restrict orchestration event/aggregate kinds to project/thread events. v1 therefore introduces **dedicated workflow tables** (`workflow_events`, `projection_board`, `projection_ticket`, `projection_pipeline_run`, `projection_step_run`, `workflow_dispatch_outbox`, `workflow_setup_run`, `worktree_lease`) and a `WorkflowEventStore` mirroring the existing event-store pattern. The orchestration contracts/union are **not** widened. The workflow engine reads orchestration state through existing projection queries (e.g. turn terminal state, pending-approval projection) — it does not write orchestration events except via the public `OrchestrationEngine.dispatch` API. ## 6. Workflow file format @@ -117,13 +117,22 @@ A persistent fencing lease, one row per ticket worktree in `worktree_lease`: `{ ### 7.2 Agent step execution -1. Ensure the ticket worktree exists. **Setup gate (resolves finding #6):** worktree creation (`vcs.createWorktree`) does **not** run project setup scripts; v1 explicitly invokes `ProjectSetupScriptRunner` and **waits for a durable completion signal** before the first agent step. Since the runner currently emits only "started", v1 adds a completion event/await (a small addition to the runner) — setup is a **gate**, not fire-and-forget. -2. Capture the **baseline checkpoint** at ticket creation if not present (§7.10). +1. Ensure the ticket worktree exists, then run setup as a **durable gate** (§7.2.1) before the first agent step. +2. Capture the **baseline ref** at ticket creation if not present (§7.10). 3. Acquire the worktree lease (§7.1); capture a **pre-step checkpoint** (`preCheckpointRef`). 4. Dispatch the provider turn through the durable path in §7.8. 5. Mark the step terminal **only when both** hold, read from persisted state: (a) the projected turn state is terminal, and (b) the post-turn checkpoint/diff is finalized. A pending question/approval maps to `StepAwaitingUser` → ticket `waiting_on_user` (§7.3). 6. On terminal success, capture the **post-step checkpoint** (`postCheckpointRef`), release the lease, record `StepCompleted` with the run snapshot. +### 7.2.1 Durable setup runs (resolves finding #6) + +`vcs.createWorktree` only creates the git worktree; `ProjectSetupScriptRunner` is a separate service that today opens a terminal and returns `status: "started"` with **no completion signal**. v1 models setup as a durable, gated run: + +- **`SetupRun` entity** (`workflow_setup_run` table): `{ id, ticketId, worktreeRef, status: "running" | "completed" | "failed" | "timed_out", exitCode, startedAt, finishedAt }`, with workflow events `SetupStarted` / `SetupCompleted` / `SetupFailed`. +- **Completion source:** the runner is extended to capture the **terminal's exit event** (the terminal manager already emits process exit with an exit code); exit `0` → `completed`, non-zero → `failed`. A configurable **timeout** marks `timed_out` and kills the process group. +- **Gate:** the first agent step for a ticket does not dispatch until `SetupRun.status == "completed"`. `failed`/`timed_out` → ticket `blocked` with the captured output. +- **Restart reconciliation:** on restart, a `running` SetupRun with no persisted terminal-exit is **re-run** from scratch (the worktree is reset to baseline first). Setup scripts must therefore be **idempotent** — documented as a requirement for `runOnWorktreeCreate` scripts used by boards. + ### 7.3 Waiting-on-user: durable gates vs. volatile provider waits (resolves finding #4) Two distinct mechanisms, surfaced identically in the UI but handled differently: @@ -152,14 +161,21 @@ A board-level `maxConcurrentTickets` caps tickets in `running` state simultaneou ### 7.8 Durable provider dispatch (resolves finding #1) -The engine must not rely on re-dispatching `thread.turn.start` for recovery, because the command is deduped while the provider side-effect (consumed off a hot stream) is not replayed. v1 introduces a **provider-dispatch outbox**: +The hazard, precisely: `OrchestrationEngine.dispatch` persists and **dedupes the command**, but the provider side-effect that actually starts the agent runs in `ProviderCommandReactor`, which consumes a **hot, non-replayed** domain-event stream. Therefore neither "command accepted" nor a naïve re-dispatch (which returns the stored receipt **without re-emitting the hot event**) proves the provider turn ran. Two facts must be kept distinct: + +- **Command intake** — `thread.turn-start-requested` is persisted the moment the command is accepted. This is *not* evidence the agent started. +- **Provider progress** — the projected turn transitions to `running`/`completed`/`failed` only after `ProviderRuntimeIngestion` observes the provider actually doing work. This *is* the durable evidence. + +v1 introduces a **provider-dispatch outbox** owned by the workflow engine, and confirms on **provider progress**, not command intake: -1. The engine writes a `ProviderDispatchRequested` row to `workflow_dispatch_outbox` with a deterministic `dispatchId` and the intended `{ threadId, messageId, commandId, providerInstance, model, instruction }`. -2. A dedicated **dispatch worker** (a durable, replayable reactor owned by the workflow engine) reads pending outbox rows and ensures the thread is created and the turn started via `OrchestrationEngine.dispatch`, keyed by `commandId` for idempotency. -3. The worker confirms by observing the **persisted** turn-start projection (not the hot stream) and writes `ProviderDispatchConfirmed`. Until confirmed, the row stays pending and is retried on a backoff. -4. On restart, the worker re-reads unconfirmed outbox rows and re-ensures — exactly-once in effect because creation/turn-start are idempotent on `commandId`/`threadId`, and confirmation is based on persisted state. +1. The engine writes a `ProviderDispatchRequested` row to `workflow_dispatch_outbox` with a deterministic `dispatchId` and `{ threadId, messageId, commandId, providerInstance, model, instruction }`. +2. A dedicated **dispatch worker** (durable, replayable, owned by the workflow engine — not the hot orchestration reactor) ensures the provider turn is actually running. Because the existing reactor does not replay, the worker must *own* the side-effect rather than assume a re-dispatch re-fires it. **Implementation options (decide in planning):** + - **(a) Workflow-driven provider start (preferred):** the worker drives `ProviderService` start/sendTurn directly — the same call the reactor makes — so retry/replay live in the workflow engine. Guarded for idempotency: before invoking, check persisted provider progress for `threadId`/turn; if it already shows `running`/terminal, skip. + - **(b) Replayable orchestration outbox:** add a durable, replayable provider-command path inside `ProviderCommandReactor` so re-dispatch re-fires. Heavier (touches shared orchestration internals); only if (a) proves insufficient. +3. The worker writes `ProviderDispatchConfirmed` **only when persisted provider progress** (turn `running`/terminal from `ProviderRuntimeIngestion`) is observed — never on command intake. Until then the outbox row stays pending and retries on backoff. +4. On restart, the worker re-reads unconfirmed rows and re-ensures **only when no persisted provider progress exists** for that thread/turn; otherwise it confirms and stops. This is exactly-once in effect: the idempotency guard in (a) prevents a duplicate turn if the original fired late. -This makes "the agent actually started" a durable, recoverable fact rather than an artifact of a live stream. +This makes "the agent actually started" a durable, recoverable fact derived from provider-side progress, not from command acceptance or a live stream. ### 7.9 Crash recovery (summary) @@ -167,12 +183,14 @@ On restart the engine rebuilds from `workflow_events` and reconciles: (a) unconf ### 7.10 Ticket-level checkpoints & accumulated diff (resolves finding #3) -Existing checkpoint refs are `threadId`+turn scoped, so a per-thread (per-step) diff is only one step's delta. v1 adds ticket-scoped checkpointing: +Existing checkpoint refs are `threadId`+turn scoped and the diff query is **ref-to-ref**, so a per-thread (per-step) diff is only one step's delta. v1 adds ticket-scoped checkpointing with explicit naming and APIs: -- At ticket creation, record a **baseline ref** for the worktree (the base commit/tree the worktree starts from), stored as `Ticket.baselineCheckpointId`. -- Around each agent step, capture `preCheckpointRef`/`postCheckpointRef` (ticket-scoped refs, e.g. `refs/t3/tickets//step//{pre,post}`), reusing `CheckpointStore`'s underlying capture/diff plumbing but under a ticket namespace. -- The **accumulated ticket diff** shown in the drill-in is a **base-to-current** diff (baseline ref → current worktree tree), a new query alongside `CheckpointDiffQuery`. Per-step diffs use `preCheckpointRef → postCheckpointRef`. -- This is explicitly net-new server work in v1 (not just reuse). +- **Baseline ref** — at ticket creation, capture `Ticket.baselineRef`: a real git ref/commit (`refs/t3/tickets//base`) pointing at the worktree's starting tree. (Renamed from the earlier `baselineCheckpointId`; it is a ref, not a checkpoint id.) +- **Per-step refs** — around each agent step, capture `preCheckpointRef`/`postCheckpointRef` under a ticket namespace (`refs/t3/tickets//step//{pre,post}`), reusing `CheckpointStore`'s capture plumbing. +- **Two distinct diff APIs:** + - *Per-step diff* = ref-to-ref `preCheckpointRef → postCheckpointRef`, served by the existing `CheckpointStore.diffCheckpoints` (ref-to-ref) — direct reuse. + - *Accumulated ticket diff* = **base-ref → live working tree** (includes uncommitted state), which `diffCheckpoints` cannot express. This needs a **new `TicketDiffQuery.diffBaseToWorktree(baselineRef, worktreeRef)`** built on the working-tree git-diff plumbing in `GitVcsDriverCore` (git diff against `baselineRef`), not on ref-to-ref checkpoint diffing. +- This is explicitly net-new server work in v1 (not pure reuse): the ticket ref namespace, the baseline capture, and `TicketDiffQuery`. ## 8. UI @@ -212,8 +230,9 @@ Parse/lint errors (§6) surface in the board UI via the existing settings file-w ## 11. Open questions (genuinely open after revision) +- **Key planning decision:** §7.8 option (a) workflow-driven `ProviderService` start vs. (b) a replayable orchestration-level provider-command outbox. (a) is preferred; confirm it can satisfy idempotency without touching shared reactor internals. - Exact module homes (`apps/server/src/workflow/`, `packages/contracts/src/workflow.ts`) — confirm against current conventions during planning. - Whether ticket worktrees live under the existing `apps/server/worktrees/` path or a board-scoped subdirectory. -- The minimal addition to `ProjectSetupScriptRunner` to emit a durable "setup completed" signal (§7.2) — confirm the cleanest event/await shape. +- Confirm the terminal-exit event is the cleanest durable completion source for `SetupRun` (§7.2.1), and the exact runner extension shape. - Whether the ticket-scoped checkpoint refs reuse `CheckpointStore` directly under a new ref namespace or warrant a thin `TicketCheckpointStore` wrapper (§7.10). - Exact RPC surface for `workflow.*` (board subscribe, ticket CRUD, answer/approve, drag, run/abort) mirroring the existing `orchestration.*` methods. From 1972ed69082b651c7cc9b25b74ad02c3cefbb145 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 00:08:49 -0400 Subject: [PATCH 003/295] docs: add workflow boards v1 Milestone 1 (foundation) implementation plan TDD, bite-sized plan for the persistence/read-model foundation: workflow contracts, file schema + linter, workflow_events store with per-ticket versioning, board/ticket/step projections, and read-model queries. No engine or RPC yet (Milestones 2 and 5). Code mirrors existing orchestration event-store/projection idioms. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-07-workflow-boards-v1-m1-foundation.md | 1609 +++++++++++++++++ 1 file changed, 1609 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md new file mode 100644 index 00000000000..7f75bf8ef61 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md @@ -0,0 +1,1609 @@ +# Workflow Boards v1 — Milestone 1: Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the persistence and read-model foundation for workflow boards: workflow contracts, the workflow file schema + linter, a `workflow_events` event store, board/ticket projections, and read-model queries — all testable by appending events and asserting projections. + +**Architecture:** A new bounded context under `apps/server/src/workflow/` with dedicated SQLite tables (`workflow_events` + projection tables), mirroring the existing orchestration event-store/projection pattern. This milestone has **no engine and no RPC** — those are Milestones 2 and 5. Everything here is verified by unit tests that append events directly to the store and assert the resulting projections, plus pure table-driven tests for the workflow-file linter. + +**Tech Stack:** TypeScript, Effect 4 (beta), `effect/Schema`, `effect/unstable/sql` (`SqlClient`, `SqlSchema`), `@effect/sql-sqlite-bun`, `@effect/vitest`. + +**Spec:** `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md` (§5 data model, §6 workflow file format). + +**v1 milestone sequence (context):** M1 Foundation (this plan) → M2 Engine core → M3 Durable agent execution → M4 Ticket checkpoints & diff → M5 RPC + UI. Each milestone gets its own plan. + +--- + +## Conventions (read once before starting) + +- **File split:** interface (`Shape` + `Context.Service`) in `…/Services/Name.ts`; implementation (`makeName` + `Layer.effect`) in `…/Layers/Name.ts`. Schemas in `packages/contracts/src/workflow.ts`. Tests colocated as `Name.test.ts`. +- **IDs:** branded via `Schema.brand()` from `TrimmedNonEmptyString` (see `packages/contracts/src/baseSchemas.ts`). +- **Events:** a discriminated `Schema.Union` keyed on a `type` `Schema.Literal`. +- **Service class:** `export class Name extends Context.Service()("t3/workflow/Services/Name") {}`. +- **Layer export:** `export const NameLive = Layer.effect(Name, makeName)`. +- **No barrel index.** Import from explicit file paths / contracts entrypoint `@t3tools/contracts`. +- **Commit after every task** (the final step of each task). + +--- + +## File Structure + +**Create:** +- `packages/contracts/src/workflow.ts` — all workflow schemas: branded IDs, tokens, workflow-file definition (lanes/steps), workflow events, projection row types. +- `apps/server/src/persistence/Migrations/0XX_WorkflowEvents.ts` — `workflow_events` + projection tables migration (number assigned in Task 5). +- `apps/server/src/workflow/Services/WorkflowEventStore.ts` — event store interface. +- `apps/server/src/workflow/Layers/WorkflowEventStore.ts` — event store implementation. +- `apps/server/src/workflow/Layers/WorkflowEventStore.test.ts` +- `apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts` — projection pipeline interface. +- `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts` — projection pipeline implementation. +- `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts` +- `apps/server/src/workflow/Services/WorkflowReadModel.ts` — read-model query interface. +- `apps/server/src/workflow/Layers/WorkflowReadModel.ts` — read-model query implementation. +- `apps/server/src/workflow/Layers/WorkflowReadModel.test.ts` +- `apps/server/src/workflow/workflowFile.ts` — workflow-file decode + linter (pure). +- `apps/server/src/workflow/workflowFile.test.ts` +- `apps/server/src/workflow/WorkflowFoundationLive.ts` — aggregated layer for this milestone. + +**Modify:** +- `apps/server/src/persistence/Migrations.ts` — register the new migration. + +--- + +## Task 1: Workflow branded IDs and tokens (contracts) + +**Files:** +- Create: `packages/contracts/src/workflow.ts` +- Test: `packages/contracts/src/workflow.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/contracts/src/workflow.test.ts +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { BoardId, TicketId, LaneEntryToken, WorkflowEventId } from "./workflow.ts"; + +describe("workflow ids", () => { + it("brands a board id from a non-empty string", () => { + const id = BoardId.make("board-123"); + assert.equal(id, "board-123"); + }); + + it("rejects an empty ticket id", () => { + const decode = Schema.decodeUnknownEither(TicketId); + assert.isTrue(decode(""). _tag === "Left"); + }); + + it("brands lane-entry tokens and event ids", () => { + assert.equal(LaneEntryToken.make("tok-1"), "tok-1"); + assert.equal(WorkflowEventId.make("evt-1"), "evt-1"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +Expected: FAIL — `Cannot find module './workflow.ts'`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// packages/contracts/src/workflow.ts +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const makeId = (brand: Brand) => + TrimmedNonEmptyString.pipe(Schema.brand(brand)); + +export const BoardId = makeId("BoardId"); +export type BoardId = typeof BoardId.Type; + +export const TicketId = makeId("TicketId"); +export type TicketId = typeof TicketId.Type; + +export const PipelineRunId = makeId("PipelineRunId"); +export type PipelineRunId = typeof PipelineRunId.Type; + +export const StepRunId = makeId("StepRunId"); +export type StepRunId = typeof StepRunId.Type; + +export const LaneEntryToken = makeId("LaneEntryToken"); +export type LaneEntryToken = typeof LaneEntryToken.Type; + +export const WorkflowEventId = makeId("WorkflowEventId"); +export type WorkflowEventId = typeof WorkflowEventId.Type; + +/** A lane/step key as authored in the workflow file (stable identifier within a board). */ +export const LaneKey = TrimmedNonEmptyString.pipe(Schema.brand("LaneKey")); +export type LaneKey = typeof LaneKey.Type; + +export const StepKey = TrimmedNonEmptyString.pipe(Schema.brand("StepKey")); +export type StepKey = typeof StepKey.Type; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/workflow.ts packages/contracts/src/workflow.test.ts +git commit -m "feat(workflow): add branded ids and keys for workflow boards" +``` + +--- + +## Task 2: Workflow file definition schema + +Models the `.t3/boards/.json` file (§6): lanes, per-lane entry, pipeline of `agent`/`approval` steps, lane-level routing. + +**Files:** +- Modify: `packages/contracts/src/workflow.ts` +- Test: `packages/contracts/src/workflow.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// append to packages/contracts/src/workflow.test.ts +import * as Effect from "effect/Effect"; +import { WorkflowDefinition } from "./workflow.ts"; + +describe("WorkflowDefinition", () => { + const example = { + name: "Standard delivery", + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "implement", name: "Implement", entry: "auto", + pipeline: [ + { key: "code", type: "agent", agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/implement.md" } }, + { key: "review", type: "agent", agent: { instance: "codex_main", model: "gpt-5.4" }, + instruction: "Review the diff." }, + ], + on: { success: "owner_review", failure: "needs_attention" }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "needs_attention", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }; + + it("decodes a valid workflow file", () => + Effect.gen(function* () { + const decoded = yield* Schema.decodeUnknown(WorkflowDefinition)(example); + assert.equal(decoded.lanes.length, 5); + assert.equal(decoded.lanes[1]?.pipeline?.length, 2); + }).pipe(Effect.runPromise)); + + it("rejects an unknown step type", () => + Schema.decodeUnknown(WorkflowDefinition)({ + name: "x", + lanes: [{ key: "a", name: "A", entry: "auto", + pipeline: [{ key: "s", type: "script", run: "echo hi" }] }], + }).pipe( + Effect.match({ onFailure: () => "rejected", onSuccess: () => "accepted" }), + Effect.map((r) => assert.equal(r, "rejected")), + Effect.runPromise, + )); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +Expected: FAIL — `WorkflowDefinition` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// append to packages/contracts/src/workflow.ts + +/** Instruction is either an inline string or a repo-relative file reference. */ +export const StepInstruction = Schema.Union([ + Schema.String, + Schema.Struct({ file: TrimmedNonEmptyString }), +]); +export type StepInstruction = typeof StepInstruction.Type; + +export const AgentSelection = Schema.Struct({ + instance: TrimmedNonEmptyString, // ProviderInstanceId at runtime; kept loose here + model: TrimmedNonEmptyString, +}); +export type AgentSelection = typeof AgentSelection.Type; + +export const AgentStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("agent"), + agent: AgentSelection, + instruction: StepInstruction, +}); + +export const ApprovalStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("approval"), + prompt: Schema.optional(Schema.String), +}); + +/** v1 step union — only agent and approval (script deferred to v2). */ +export const WorkflowStep = Schema.Union([AgentStep, ApprovalStep]); +export type WorkflowStep = typeof WorkflowStep.Type; + +export const LaneEntry = Schema.Literal("auto", "manual"); +export type LaneEntry = typeof LaneEntry.Type; + +export const LaneRouting = Schema.Struct({ + success: Schema.optional(LaneKey), + failure: Schema.optional(LaneKey), + blocked: Schema.optional(LaneKey), +}); + +export const WorkflowLane = Schema.Struct({ + key: LaneKey, + name: TrimmedNonEmptyString, + entry: LaneEntry, + pipeline: Schema.optional(Schema.Array(WorkflowStep)), + on: Schema.optional(LaneRouting), + wipLimit: Schema.optional(Schema.Int), + color: Schema.optional(Schema.String), + terminal: Schema.optional(Schema.Boolean), +}); +export type WorkflowLane = typeof WorkflowLane.Type; + +export const WorkflowSettings = Schema.Struct({ + // optional in the file; the loader applies a default of 3 when absent (Milestone 2) + maxConcurrentTickets: Schema.optional(Schema.Int), +}); + +export const WorkflowDefinition = Schema.Struct({ + name: TrimmedNonEmptyString, + settings: Schema.optional(WorkflowSettings), + lanes: Schema.Array(WorkflowLane), +}); +export type WorkflowDefinition = typeof WorkflowDefinition.Type; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/workflow.ts packages/contracts/src/workflow.test.ts +git commit -m "feat(workflow): add workflow file definition schema (lanes/steps/routing)" +``` + +--- + +## Task 3: Workflow file linter + +Pure validation beyond schema decode (§6): duplicate keys, routing targets to missing lanes, `auto`-lane cycles with no human/terminal break, unknown provider instances, missing instruction files. Provider/file existence are injected so the linter stays pure and testable. + +**Files:** +- Create: `apps/server/src/workflow/workflowFile.ts` +- Test: `apps/server/src/workflow/workflowFile.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/workflowFile.test.ts +import { assert, describe, it } from "@effect/vitest"; +import type { WorkflowDefinition } from "@t3tools/contracts"; +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const base = (lanes: unknown): WorkflowDefinition => + ({ name: "wf", lanes } as unknown as WorkflowDefinition); + +const ctx = { + providerInstanceExists: (id: string) => id === "claude_main", + instructionFileExists: (path: string) => path === "prompts/ok.md", +}; + +describe("lintWorkflowDefinition", () => { + it("passes a valid definition", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "auto", + pipeline: [{ key: "s", type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" } }], + on: { success: "done" } }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.deepEqual(errors, []); + }); + + it("flags duplicate lane keys", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "manual" }, + { key: "a", name: "A2", entry: "manual" }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "duplicate_lane_key")); + }); + + it("flags routing to a missing lane", () => { + const errors = lintWorkflowDefinition( + base([{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_lane_ref")); + }); + + it("flags an unknown provider instance", () => { + const errors = lintWorkflowDefinition( + base([{ key: "a", name: "A", entry: "auto", + pipeline: [{ key: "s", type: "agent", + agent: { instance: "nope", model: "x" }, instruction: "hi" }] }]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "unknown_provider_instance")); + }); + + it("flags a missing instruction file", () => { + const errors = lintWorkflowDefinition( + base([{ key: "a", name: "A", entry: "auto", + pipeline: [{ key: "s", type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "prompts/missing.md" } }] }]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_instruction_file")); + }); + + it("flags an auto-lane cycle with no human/terminal break", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "auto", on: { success: "b" } }, + { key: "b", name: "B", entry: "auto", on: { success: "a" } }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "auto_lane_cycle")); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- workflowFile.test.ts` +Expected: FAIL — `lintWorkflowDefinition` not found. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// apps/server/src/workflow/workflowFile.ts +import type { WorkflowDefinition, WorkflowLane } from "@t3tools/contracts"; + +export type LintCode = + | "duplicate_lane_key" + | "duplicate_step_key" + | "missing_lane_ref" + | "unknown_provider_instance" + | "missing_instruction_file" + | "auto_lane_cycle" + | "unreachable_terminal"; + +export interface LintError { + readonly code: LintCode; + readonly message: string; + readonly laneKey?: string; + readonly stepKey?: string; +} + +export interface LintContext { + readonly providerInstanceExists: (instanceId: string) => boolean; + readonly instructionFileExists: (repoRelativePath: string) => boolean; +} + +const routingTargets = (lane: WorkflowLane): ReadonlyArray => { + const on = lane.on; + if (!on) return []; + return [on.success, on.failure, on.blocked].filter( + (t): t is string => typeof t === "string", + ); +}; + +export const lintWorkflowDefinition = ( + def: WorkflowDefinition, + ctx: LintContext, +): ReadonlyArray => { + const errors: LintError[] = []; + const laneKeys = new Set(); + const allKeys = def.lanes.map((l) => l.key as string); + + for (const lane of def.lanes) { + const key = lane.key as string; + if (laneKeys.has(key)) { + errors.push({ code: "duplicate_lane_key", laneKey: key, + message: `Duplicate lane key "${key}"` }); + } + laneKeys.add(key); + + const stepKeys = new Set(); + for (const step of lane.pipeline ?? []) { + const sk = step.key as string; + if (stepKeys.has(sk)) { + errors.push({ code: "duplicate_step_key", laneKey: key, stepKey: sk, + message: `Duplicate step key "${sk}" in lane "${key}"` }); + } + stepKeys.add(sk); + if (step.type === "agent") { + if (!ctx.providerInstanceExists(step.agent.instance)) { + errors.push({ code: "unknown_provider_instance", laneKey: key, stepKey: sk, + message: `Unknown provider instance "${step.agent.instance}"` }); + } + if (typeof step.instruction === "object" && + !ctx.instructionFileExists(step.instruction.file)) { + errors.push({ code: "missing_instruction_file", laneKey: key, stepKey: sk, + message: `Instruction file not found: "${step.instruction.file}"` }); + } + } + } + + for (const target of routingTargets(lane)) { + if (!allKeys.includes(target)) { + errors.push({ code: "missing_lane_ref", laneKey: key, + message: `Lane "${key}" routes to missing lane "${target}"` }); + } + } + } + + // Auto-lane cycle detection: follow `on.success` edges among auto lanes only; + // a cycle with no manual/terminal lane on the path is an error. + const byKey = new Map(def.lanes.map((l) => [l.key as string, l] as const)); + for (const lane of def.lanes) { + if (lane.entry !== "auto") continue; + const seen = new Set(); + let cursor: WorkflowLane | undefined = lane; + while (cursor && cursor.entry === "auto" && !cursor.terminal) { + const ck = cursor.key as string; + if (seen.has(ck)) { + errors.push({ code: "auto_lane_cycle", laneKey: lane.key as string, + message: `Auto-lane cycle detected starting at "${lane.key}"` }); + break; + } + seen.add(ck); + const next = cursor.on?.success; + cursor = next ? byKey.get(next) : undefined; + } + } + + return errors; +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @t3tools/server test -- workflowFile.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/workflow/workflowFile.ts apps/server/src/workflow/workflowFile.test.ts +git commit -m "feat(workflow): add pure workflow-file linter" +``` + +--- + +## Task 4: Workflow event union (contracts) + +The foundation's projections consume these. Event base fields mirror the orchestration store columns (event id, stream id = ticket id, version, occurredAt, payload). + +**Files:** +- Modify: `packages/contracts/src/workflow.ts` +- Test: `packages/contracts/src/workflow.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// append to packages/contracts/src/workflow.test.ts +import { WorkflowEvent } from "./workflow.ts"; + +describe("WorkflowEvent", () => { + const ticketCreated = { + type: "TicketCreated", + eventId: "evt-1", + ticketId: "t-1", + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z", + payload: { boardId: "b-1", title: "Add export", laneKey: "backlog" }, + }; + + it("decodes a TicketCreated event", () => + Effect.gen(function* () { + const e = yield* Schema.decodeUnknown(WorkflowEvent)(ticketCreated); + assert.equal(e.type, "TicketCreated"); + }).pipe(Effect.runPromise)); + + it("decodes a TicketMovedToLane event", () => + Effect.gen(function* () { + const e = yield* Schema.decodeUnknown(WorkflowEvent)({ + type: "TicketMovedToLane", eventId: "evt-2", ticketId: "t-1", + streamVersion: 1, occurredAt: "2026-06-07T00:00:01.000Z", + payload: { toLane: "implement", laneEntryToken: "tok-1", reason: "manual" }, + }); + assert.equal(e.type, "TicketMovedToLane"); + }).pipe(Effect.runPromise)); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +Expected: FAIL — `WorkflowEvent` not exported. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// append to packages/contracts/src/workflow.ts +import { IsoDateTime } from "./baseSchemas.ts"; + +const EventBase = { + eventId: WorkflowEventId, + ticketId: TicketId, // stream id for v1 is always the ticket + streamVersion: Schema.Int, + occurredAt: IsoDateTime, +}; + +export const TicketStatus = Schema.Literal( + "idle", "running", "waiting_on_user", "blocked", "done", "failed", +); +export type TicketStatus = typeof TicketStatus.Type; + +export const StepRunStatus = Schema.Literal( + "pending", "dispatch_requested", "running", "awaiting_user", + "completed", "failed", "superseded", +); +export type StepRunStatus = typeof StepRunStatus.Type; + +export const WorkflowEvent = Schema.Union([ + Schema.Struct({ ...EventBase, type: Schema.Literal("TicketCreated"), + payload: Schema.Struct({ boardId: BoardId, title: TrimmedNonEmptyString, + laneKey: LaneKey, description: Schema.optional(Schema.String) }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("TicketMovedToLane"), + payload: Schema.Struct({ toLane: LaneKey, laneEntryToken: LaneEntryToken, + reason: Schema.Literal("manual", "routed", "initial") }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("TicketBlocked"), + payload: Schema.Struct({ reason: Schema.String }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("PipelineStarted"), + payload: Schema.Struct({ pipelineRunId: PipelineRunId, laneKey: LaneKey, + laneEntryToken: LaneEntryToken }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("PipelineCompleted"), + payload: Schema.Struct({ pipelineRunId: PipelineRunId, + result: Schema.Literal("success", "failure", "blocked", "superseded") }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("StepStarted"), + payload: Schema.Struct({ pipelineRunId: PipelineRunId, stepRunId: StepRunId, + stepKey: StepKey, stepType: Schema.Literal("agent", "approval") }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("StepAwaitingUser"), + payload: Schema.Struct({ stepRunId: StepRunId, waitingReason: Schema.String }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("StepUserResolved"), + payload: Schema.Struct({ stepRunId: StepRunId }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("StepCompleted"), + payload: Schema.Struct({ stepRunId: StepRunId }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("StepFailed"), + payload: Schema.Struct({ stepRunId: StepRunId, error: Schema.String }) }), + + Schema.Struct({ ...EventBase, type: Schema.Literal("TicketRouted"), + payload: Schema.Struct({ fromLane: LaneKey, toLane: LaneKey }) }), +]); +export type WorkflowEvent = typeof WorkflowEvent.Type; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/workflow.ts packages/contracts/src/workflow.test.ts +git commit -m "feat(workflow): add workflow event union" +``` + +--- + +## Task 5: Migration — workflow tables + +**Files:** +- Create: `apps/server/src/persistence/Migrations/0XX_WorkflowEvents.ts` (assign the next free number — check `Migrations.ts` `migrationEntries`; the spec assumes ~033+). +- Modify: `apps/server/src/persistence/Migrations.ts` +- Test: `apps/server/src/workflow/Layers/WorkflowEventStore.test.ts` (created here, asserts tables exist; expanded in Task 6). + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEventStore.test.ts +import { assert } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { it } from "@effect/vitest"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("workflow migration", (it) => { + it.effect("creates workflow_events and projection tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' + AND name IN ('workflow_events','projection_board','projection_ticket', + 'projection_pipeline_run','projection_step_run') + `; + assert.equal(tables.length, 5); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEventStore.test.ts` +Expected: FAIL — only 0 of 5 tables found. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// apps/server/src/persistence/Migrations/0XX_WorkflowEvents.ts +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + ticket_id TEXT NOT NULL, + stream_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + occurred_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_stream_version + ON workflow_events(ticket_id, stream_version) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_board ( + board_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + workflow_file_path TEXT NOT NULL, + workflow_version_hash TEXT NOT NULL, + max_concurrent_tickets INTEGER NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket ( + ticket_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + current_lane_key TEXT NOT NULL, + status TEXT NOT NULL, + worktree_ref TEXT, + baseline_ref TEXT, + external_ref TEXT, + priority INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_board + ON projection_ticket(board_id) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_pipeline_run ( + pipeline_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + lane_key TEXT NOT NULL, + lane_entry_token TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_step_run ( + step_run_id TEXT PRIMARY KEY, + pipeline_run_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + step_key TEXT NOT NULL, + step_type TEXT NOT NULL, + status TEXT NOT NULL, + waiting_reason TEXT, + error TEXT, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_step_run_ticket + ON projection_step_run(ticket_id) + `; +}); +``` + +Then register it. Open `apps/server/src/persistence/Migrations.ts`, add the static import alongside the others and append to `migrationEntries` (use the next integer after the current last entry): + +```typescript +// near the other imports +import Migration00XXWorkflowEvents from "./Migrations/0XX_WorkflowEvents.ts"; + +// in the migrationEntries array, as the new last element (replace XX with the next number) + [XX, "WorkflowEvents", Migration00XXWorkflowEvents], +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEventStore.test.ts` +Expected: PASS — 5 tables found. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/persistence/Migrations/0XX_WorkflowEvents.ts apps/server/src/persistence/Migrations.ts apps/server/src/workflow/Layers/WorkflowEventStore.test.ts +git commit -m "feat(workflow): add workflow_events and projection tables migration" +``` + +--- + +## Task 6: WorkflowEventStore service + layer + +Append (with per-ticket optimistic versioning) and stream reads. Mirrors `OrchestrationEventStore`. + +**Files:** +- Create: `apps/server/src/workflow/Services/WorkflowEventStore.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowEventStore.ts` +- Modify: `apps/server/src/workflow/Layers/WorkflowEventStore.test.ts` + +- [ ] **Step 1: Write the failing test (append to the file from Task 5)** + +```typescript +// append a second `layer(...)` block to WorkflowEventStore.test.ts +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import * as Stream from "effect/Stream"; + +const storeLayer = it.layer( + WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +storeLayer("WorkflowEventStore", (it) => { + it.effect("appends and replays a decoded event with assigned version", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const appended = yield* store.append({ + type: "TicketCreated", + eventId: "evt-a" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + assert.equal(appended.streamVersion, 0); + + const events = yield* Stream.runCollect(store.readByTicket("t-1" as never)).pipe( + Effect.map((c) => Array.from(c)), + ); + assert.equal(events.length, 1); + assert.equal(events[0]?.type, "TicketCreated"); + }), + ); + + it.effect("assigns incrementing stream versions per ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + yield* store.append({ type: "TicketCreated", eventId: "evt-b" as never, + ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "backlog" as never } }); + const second = yield* store.append({ type: "TicketBlocked", eventId: "evt-c" as never, + ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "scope unclear" } }); + assert.equal(second.streamVersion, 1); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEventStore.test.ts` +Expected: FAIL — `WorkflowEventStore` not found. + +- [ ] **Step 3: Write the service interface** + +```typescript +// apps/server/src/workflow/Services/WorkflowEventStore.ts +import type { TicketId, WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** Persisted event carries its global sequence in addition to per-ticket version. */ +export type PersistedWorkflowEvent = WorkflowEvent & { readonly sequence: number }; + +/** Distributive omit so each union member keeps its type↔payload correlation. */ +type DistributiveOmit = T extends unknown ? Omit : never; +/** Append input: the store assigns `streamVersion`, so callers must not supply it. */ +export type WorkflowEventInput = DistributiveOmit; + +export interface WorkflowEventStoreShape { + readonly append: ( + event: WorkflowEventInput, + ) => Effect.Effect; + readonly readByTicket: ( + ticketId: TicketId, + ) => Stream.Stream; + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + readonly readAll: () => Stream.Stream; +} + +export class WorkflowEventStore extends Context.Service< + WorkflowEventStore, + WorkflowEventStoreShape +>()("t3/workflow/Services/WorkflowEventStore") {} +``` + +```typescript +// apps/server/src/workflow/Services/Errors.ts +import * as Schema from "effect/Schema"; + +export class WorkflowEventStoreError extends Schema.TaggedError()( + "WorkflowEventStoreError", + { message: Schema.String, cause: Schema.optional(Schema.Defect) }, +) {} +``` + +- [ ] **Step 4: Write the layer implementation** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEventStore.ts +import { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventStoreShape, +} from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +interface Row { + readonly sequence: number; + readonly eventId: string; + readonly ticketId: string; + readonly streamVersion: number; + readonly type: string; + readonly occurredAt: string; + readonly payloadJson: string; +} + +const decodeEvent = (row: Row): Effect.Effect => + Schema.decodeUnknown(WorkflowEvent)({ + type: row.type, + eventId: row.eventId, + ticketId: row.ticketId, + streamVersion: row.streamVersion, + occurredAt: row.occurredAt, + payload: JSON.parse(row.payloadJson), + }).pipe( + Effect.map((e) => ({ ...e, sequence: row.sequence }) as PersistedWorkflowEvent), + Effect.mapError((cause) => + new WorkflowEventStoreError({ message: "Failed to decode workflow event", cause }), + ), + ); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const append: WorkflowEventStoreShape["append"] = (event) => + Effect.gen(function* () { + const rows = yield* sql` + INSERT INTO workflow_events + (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) + VALUES ( + ${event.eventId}, ${event.ticketId}, + COALESCE( + (SELECT stream_version + 1 FROM workflow_events + WHERE ticket_id = ${event.ticketId} + ORDER BY stream_version DESC LIMIT 1), 0), + ${event.type}, ${event.occurredAt}, ${JSON.stringify(event.payload)} + ) + RETURNING sequence, event_id AS "eventId", ticket_id AS "ticketId", + stream_version AS "streamVersion", event_type AS "type", + occurred_at AS "occurredAt", payload_json AS "payloadJson" + `; + const row = rows[0]; + if (!row) { + return yield* Effect.fail( + new WorkflowEventStoreError({ message: "append returned no row" }), + ); + } + return yield* decodeEvent(row); + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(new WorkflowEventStoreError({ message: "append failed", cause })), + ), + ); + + const streamRows = ( + query: Effect.Effect, unknown>, + ): Stream.Stream => + Stream.fromIterableEffect( + query.pipe( + Effect.mapError((cause) => + new WorkflowEventStoreError({ message: "read failed", cause }), + ), + ), + ).pipe(Stream.mapEffect(decodeEvent)); + + const readByTicket: WorkflowEventStoreShape["readByTicket"] = (ticketId) => + streamRows(sql` + SELECT sequence, event_id AS "eventId", ticket_id AS "ticketId", + stream_version AS "streamVersion", event_type AS "type", + occurred_at AS "occurredAt", payload_json AS "payloadJson" + FROM workflow_events WHERE ticket_id = ${ticketId} + ORDER BY stream_version ASC + `); + + const readFromSequence: WorkflowEventStoreShape["readFromSequence"] = (seq, limit = 1000) => + streamRows(sql` + SELECT sequence, event_id AS "eventId", ticket_id AS "ticketId", + stream_version AS "streamVersion", event_type AS "type", + occurred_at AS "occurredAt", payload_json AS "payloadJson" + FROM workflow_events WHERE sequence > ${seq} + ORDER BY sequence ASC LIMIT ${limit} + `); + + const readAll: WorkflowEventStoreShape["readAll"] = () => readFromSequence(0, 1_000_000); + + return { append, readByTicket, readFromSequence, readAll } satisfies WorkflowEventStoreShape; +}); + +export const WorkflowEventStoreLive = Layer.effect(WorkflowEventStore, make); +``` + +> Note: confirm the exact `SqlError` tag name and `Stream.fromIterableEffect` availability against the installed Effect beta; if the helper differs, mirror whatever `OrchestrationEventStore.ts` uses (it solves the identical problem). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEventStore.test.ts` +Expected: PASS (migration test + 2 store tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/workflow/Services/WorkflowEventStore.ts apps/server/src/workflow/Services/Errors.ts apps/server/src/workflow/Layers/WorkflowEventStore.ts apps/server/src/workflow/Layers/WorkflowEventStore.test.ts +git commit -m "feat(workflow): add WorkflowEventStore with per-ticket versioning" +``` + +--- + +## Task 7: Board + ticket projection pipeline + +Applies events to `projection_ticket` / `projection_pipeline_run` / `projection_step_run`. `projection_board` rows are written by a board-registration call (Task 8) — projections here cover ticket/run state. + +**Files:** +- Create: `apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; + +const layer = it.layer( + WorkflowProjectionPipelineLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowProjectionPipeline", (it) => { + it.effect("projects TicketCreated then TicketMovedToLane into projection_ticket", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", eventId: "e1" as never, ticketId: "t-1" as never, + streamVersion: 0, occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Export CSV" as never, + laneKey: "backlog" as never }, + }); + yield* pipeline.projectEvent({ + type: "TicketMovedToLane", eventId: "e2" as never, ticketId: "t-1" as never, + streamVersion: 1, occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { toLane: "implement" as never, laneEntryToken: "tok-1" as never, + reason: "routed" }, + }); + + const rows = yield* sql<{ readonly currentLaneKey: string; readonly status: string }>` + SELECT current_lane_key AS "currentLaneKey", status FROM projection_ticket + WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.currentLaneKey, "implement"); + assert.equal(rows[0]?.status, "idle"); + }), + ); + + it.effect("projects step lifecycle and waiting_on_user status", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ ...base, type: "TicketCreated", eventId: "a" as never, + streamVersion: 0, payload: { boardId: "b-1" as never, title: "Y" as never, + laneKey: "implement" as never } }); + yield* pipeline.projectEvent({ ...base, type: "PipelineStarted", eventId: "b" as never, + streamVersion: 1, payload: { pipelineRunId: "pr-1" as never, + laneKey: "implement" as never, laneEntryToken: "tok-1" as never } }); + yield* pipeline.projectEvent({ ...base, type: "StepStarted", eventId: "c" as never, + streamVersion: 2, payload: { pipelineRunId: "pr-1" as never, stepRunId: "sr-1" as never, + stepKey: "code" as never, stepType: "agent" } }); + yield* pipeline.projectEvent({ ...base, type: "StepAwaitingUser", eventId: "d" as never, + streamVersion: 3, payload: { stepRunId: "sr-1" as never, waitingReason: "which API?" } }); + + const ticket = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_ticket WHERE ticket_id = 't-2'`; + const step = yield* sql<{ readonly status: string; readonly waitingReason: string }>` + SELECT status, waiting_reason AS "waitingReason" FROM projection_step_run + WHERE step_run_id = 'sr-1'`; + assert.equal(ticket[0]?.status, "waiting_on_user"); + assert.equal(step[0]?.status, "awaiting_user"); + assert.equal(step[0]?.waitingReason, "which API?"); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowProjectionPipeline.test.ts` +Expected: FAIL — `WorkflowProjectionPipeline` not found. + +- [ ] **Step 3: Write the service interface** + +```typescript +// apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowProjectionPipelineShape { + readonly projectEvent: ( + event: WorkflowEvent, + ) => Effect.Effect; +} + +export class WorkflowProjectionPipeline extends Context.Service< + WorkflowProjectionPipeline, + WorkflowProjectionPipelineShape +>()("t3/workflow/Services/WorkflowProjectionPipeline") {} +``` + +- [ ] **Step 4: Write the layer implementation** + +```typescript +// apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + WorkflowProjectionPipeline, + type WorkflowProjectionPipelineShape, +} from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const projectEvent: WorkflowProjectionPipelineShape["projectEvent"] = (event) => + Effect.gen(function* () { + switch (event.type) { + case "TicketCreated": { + yield* sql` + INSERT INTO projection_ticket + (ticket_id, board_id, title, description, current_lane_key, status, + created_at, updated_at) + VALUES (${event.ticketId}, ${event.payload.boardId}, ${event.payload.title}, + ${event.payload.description ?? null}, ${event.payload.laneKey}, 'idle', + ${event.occurredAt}, ${event.occurredAt}) + ON CONFLICT(ticket_id) DO NOTHING + `; + break; + } + case "TicketMovedToLane": { + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, status = 'idle', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketRouted": { + yield* sql` + UPDATE projection_ticket SET current_lane_key = ${event.payload.toLane}, + updated_at = ${event.occurredAt} WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketBlocked": { + yield* sql` + UPDATE projection_ticket SET status = 'blocked', updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineStarted": { + yield* sql` + INSERT INTO projection_pipeline_run + (pipeline_run_id, ticket_id, lane_key, lane_entry_token, status, started_at) + VALUES (${event.payload.pipelineRunId}, ${event.ticketId}, ${event.payload.laneKey}, + ${event.payload.laneEntryToken}, 'running', ${event.occurredAt}) + ON CONFLICT(pipeline_run_id) DO NOTHING + `; + yield* sql` + UPDATE projection_ticket SET status = 'running', updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineCompleted": { + yield* sql` + UPDATE projection_pipeline_run SET status = ${event.payload.result}, + finished_at = ${event.occurredAt} WHERE pipeline_run_id = ${event.payload.pipelineRunId} + `; + break; + } + case "StepStarted": { + yield* sql` + INSERT INTO projection_step_run + (step_run_id, pipeline_run_id, ticket_id, step_key, step_type, status, started_at) + VALUES (${event.payload.stepRunId}, ${event.payload.pipelineRunId}, ${event.ticketId}, + ${event.payload.stepKey}, ${event.payload.stepType}, 'running', ${event.occurredAt}) + ON CONFLICT(step_run_id) DO NOTHING + `; + break; + } + case "StepAwaitingUser": { + yield* sql` + UPDATE projection_step_run SET status = 'awaiting_user', + waiting_reason = ${event.payload.waitingReason} WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket SET status = 'waiting_on_user', updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepUserResolved": { + yield* sql` + UPDATE projection_step_run SET status = 'running', waiting_reason = NULL + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket SET status = 'running', updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepCompleted": { + yield* sql` + UPDATE projection_step_run SET status = 'completed', finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepFailed": { + yield* sql` + UPDATE projection_step_run SET status = 'failed', error = ${event.payload.error}, + finished_at = ${event.occurredAt} WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + } + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(new WorkflowEventStoreError({ message: "projection failed", cause })), + ), + ); + + return { projectEvent } satisfies WorkflowProjectionPipelineShape; +}); + +export const WorkflowProjectionPipelineLive = Layer.effect(WorkflowProjectionPipeline, make); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm --filter @t3tools/server test -- WorkflowProjectionPipeline.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts +git commit -m "feat(workflow): add board/ticket projection pipeline" +``` + +--- + +## Task 8: WorkflowReadModel query service + +Read-side queries the UI/engine use: register a board, get board, list tickets, get ticket detail (ticket + its step runs). + +**Files:** +- Create: `apps/server/src/workflow/Services/WorkflowReadModel.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowReadModel.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowReadModel.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowReadModel.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const layer = it.layer( + Layer.mergeAll(WorkflowReadModelLive, WorkflowProjectionPipelineLive).pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowReadModel", (it) => { + it.effect("registers a board and lists its tickets", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + + yield* read.registerBoard({ + boardId: "b-1" as never, projectId: "p-1" as never, name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", workflowVersionHash: "hash1", + maxConcurrentTickets: 3, + }); + yield* pipeline.projectEvent({ type: "TicketCreated", eventId: "e1" as never, + ticketId: "t-1" as never, streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Export" as never, + laneKey: "backlog" as never } }); + + const board = yield* read.getBoard("b-1" as never); + assert.equal(board?.name, "Delivery"); + const tickets = yield* read.listTickets("b-1" as never); + assert.equal(tickets.length, 1); + assert.equal(tickets[0]?.title, "Export"); + }), + ); + + it.effect("returns ticket detail with step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { ticketId: "t-9" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + yield* pipeline.projectEvent({ ...base, type: "TicketCreated", eventId: "a" as never, + streamVersion: 0, payload: { boardId: "b-1" as never, title: "Z" as never, + laneKey: "implement" as never } }); + yield* pipeline.projectEvent({ ...base, type: "PipelineStarted", eventId: "b" as never, + streamVersion: 1, payload: { pipelineRunId: "pr" as never, + laneKey: "implement" as never, laneEntryToken: "tok" as never } }); + yield* pipeline.projectEvent({ ...base, type: "StepStarted", eventId: "c" as never, + streamVersion: 2, payload: { pipelineRunId: "pr" as never, stepRunId: "sr" as never, + stepKey: "code" as never, stepType: "agent" } }); + + const detail = yield* read.getTicketDetail("t-9" as never); + assert.equal(detail?.ticket.title, "Z"); + assert.equal(detail?.steps.length, 1); + assert.equal(detail?.steps[0]?.stepKey, "code"); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowReadModel.test.ts` +Expected: FAIL — `WorkflowReadModel` not found. + +- [ ] **Step 3: Write the service interface** + +```typescript +// apps/server/src/workflow/Services/WorkflowReadModel.ts +import type { BoardId, ProjectId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface BoardRow { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; +} +export interface TicketRow { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly currentLaneKey: string; + readonly status: string; +} +export interface StepRunRow { + readonly stepRunId: string; + readonly stepKey: string; + readonly stepType: string; + readonly status: string; + readonly waitingReason: string | null; +} +export interface TicketDetail { + readonly ticket: TicketRow; + readonly steps: ReadonlyArray; +} + +export interface WorkflowReadModelShape { + readonly registerBoard: (board: { + readonly boardId: BoardId; readonly projectId: ProjectId; readonly name: string; + readonly workflowFilePath: string; readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + }) => Effect.Effect; + readonly getBoard: (boardId: BoardId) => Effect.Effect; + readonly listTickets: ( + boardId: BoardId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly getTicketDetail: ( + ticketId: TicketId, + ) => Effect.Effect; +} + +export class WorkflowReadModel extends Context.Service< + WorkflowReadModel, + WorkflowReadModelShape +>()("t3/workflow/Services/WorkflowReadModel") {} +``` + +- [ ] **Step 4: Write the layer implementation** + +```typescript +// apps/server/src/workflow/Layers/WorkflowReadModel.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + WorkflowReadModel, + type BoardRow, + type StepRunRow, + type TicketRow, + type WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const wrap = (eff: Effect.Effect) => + eff.pipe( + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "read failed", cause })), + ); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const registerBoard: WorkflowReadModelShape["registerBoard"] = (b) => + wrap(sql` + INSERT INTO projection_board + (board_id, project_id, name, workflow_file_path, workflow_version_hash, max_concurrent_tickets) + VALUES (${b.boardId}, ${b.projectId}, ${b.name}, ${b.workflowFilePath}, + ${b.workflowVersionHash}, ${b.maxConcurrentTickets}) + ON CONFLICT(board_id) DO UPDATE SET + name = excluded.name, workflow_file_path = excluded.workflow_file_path, + workflow_version_hash = excluded.workflow_version_hash, + max_concurrent_tickets = excluded.max_concurrent_tickets + `).pipe(Effect.asVoid); + + const getBoard: WorkflowReadModelShape["getBoard"] = (boardId) => + wrap(sql` + SELECT board_id AS "boardId", project_id AS "projectId", name, + workflow_file_path AS "workflowFilePath", workflow_version_hash AS "workflowVersionHash", + max_concurrent_tickets AS "maxConcurrentTickets" + FROM projection_board WHERE board_id = ${boardId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const listTickets: WorkflowReadModelShape["listTickets"] = (boardId) => + wrap(sql` + SELECT ticket_id AS "ticketId", board_id AS "boardId", title, + current_lane_key AS "currentLaneKey", status + FROM projection_ticket WHERE board_id = ${boardId} ORDER BY created_at ASC + `); + + const getTicketDetail: WorkflowReadModelShape["getTicketDetail"] = (ticketId) => + Effect.gen(function* () { + const ticketRows = yield* wrap(sql` + SELECT ticket_id AS "ticketId", board_id AS "boardId", title, + current_lane_key AS "currentLaneKey", status + FROM projection_ticket WHERE ticket_id = ${ticketId} + `); + const ticket = ticketRows[0]; + if (!ticket) return null; + const steps = yield* wrap(sql` + SELECT step_run_id AS "stepRunId", step_key AS "stepKey", step_type AS "stepType", + status, waiting_reason AS "waitingReason" + FROM projection_step_run WHERE ticket_id = ${ticketId} ORDER BY started_at ASC + `); + return { ticket, steps }; + }); + + return { registerBoard, getBoard, listTickets, getTicketDetail } satisfies WorkflowReadModelShape; +}); + +export const WorkflowReadModelLive = Layer.effect(WorkflowReadModel, make); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm --filter @t3tools/server test -- WorkflowReadModel.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/workflow/Services/WorkflowReadModel.ts apps/server/src/workflow/Layers/WorkflowReadModel.ts apps/server/src/workflow/Layers/WorkflowReadModel.test.ts +git commit -m "feat(workflow): add WorkflowReadModel query service" +``` + +--- + +## Task 9: Aggregated foundation layer + typecheck + +Bundle the milestone's layers so later milestones (and tests) compose one entry, and confirm the whole package typechecks. + +**Files:** +- Create: `apps/server/src/workflow/WorkflowFoundationLive.ts` +- Test: `apps/server/src/workflow/WorkflowFoundationLive.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/WorkflowFoundationLive.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; + +const layer = it.layer( + WorkflowFoundationLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowFoundationLive", (it) => { + it.effect("provides event store and read model together", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const read = yield* WorkflowReadModel; + assert.isDefined(store.append); + assert.isDefined(read.getBoard); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowFoundationLive.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// apps/server/src/workflow/WorkflowFoundationLive.ts +import * as Layer from "effect/Layer"; +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowProjectionPipelineLive } from "./Layers/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +/** + * Milestone-1 foundation: event store, projection pipeline, and read model. + * Requires SqlClient + Migrations to be provided by the composing layer. + */ +export const WorkflowFoundationLive = Layer.mergeAll( + WorkflowEventStoreLive, + WorkflowProjectionPipelineLive, + WorkflowReadModelLive, +); +``` + +- [ ] **Step 4: Run test + typecheck to verify they pass** + +Run: `pnpm --filter @t3tools/server test -- WorkflowFoundationLive.test.ts` +Expected: PASS. + +Run: `pnpm --filter @t3tools/server typecheck` (use the repo's actual typecheck script — check `package.json`; it may be `pnpm -w typecheck` or `tsc -b`). +Expected: PASS — no type errors in `apps/server/src/workflow/` or `packages/contracts/src/workflow.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/workflow/WorkflowFoundationLive.ts apps/server/src/workflow/WorkflowFoundationLive.test.ts +git commit -m "feat(workflow): aggregate foundation layer (store + projections + read model)" +``` + +--- + +## Milestone Done — Definition of Done + +- [ ] All tasks' tests pass: `pnpm --filter @t3tools/contracts test -- workflow.test.ts` and `pnpm --filter @t3tools/server test -- workflow` are green. +- [ ] `pnpm --filter @t3tools/server typecheck` passes. +- [ ] You can: define a board's workflow file (schema + linter), append workflow events, and read back projected board/ticket/step state — all without an engine or RPC. +- [ ] **Next:** Milestone 2 (Engine core) — emits these events from lane/pipeline logic with stubbed provider dispatch. Write that plan next. + +--- + +## Notes for the implementer + +- **Effect beta drift:** exact import paths (`effect/unstable/sql/SqlClient`), error tags (`SqlError`), and stream helpers may differ slightly in the installed beta. When a helper here doesn't match, open the nearest existing equivalent (`apps/server/src/persistence/Layers/OrchestrationEventStore.ts`, `…/Sqlite.ts`) and mirror it exactly — those files solve the identical problems and are the source of truth for idioms. +- **Migration number:** Task 5 says `0XX` — read `apps/server/src/persistence/Migrations.ts` `migrationEntries` and use the next integer; name the file to match (`033_WorkflowEvents.ts` if 32 is current last). +- **`as never` casts in tests:** used only to keep test fixtures terse against branded IDs; production code should construct IDs via `BoardId.make(...)` etc. If the repo lints against `as never`, replace with `BoardId.make("…")` calls. +- **No engine logic here.** If you find yourself writing lane-routing or provider-dispatch code, stop — that's Milestone 2/3. From 63cf2154c376f2bfa09e5f68eb892a3b2c8d1d62 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 00:17:39 -0400 Subject: [PATCH 004/295] docs: add workflow boards v1 Milestone 2 (engine core) implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State machine over the M1 foundation: create tickets, run lane pipelines step-by-step, lane-level routing guarded by lane-entry tokens, manual drag supersede, approval gates, and per-board concurrency — with provider step execution stubbed behind a StepExecutor port (real impl is M3). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-07-workflow-boards-v1-m2-engine-core.md | 1262 +++++++++++++++++ 1 file changed, 1262 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md new file mode 100644 index 00000000000..6053ea1f3c1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md @@ -0,0 +1,1262 @@ +# Workflow Boards v1 — Milestone 2: Engine Core — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the workflow state machine: create tickets, run lane pipelines step-by-step, route between lanes on results (guarded by lane-entry tokens), handle manual drag and approval gates, and enforce per-board concurrency — with **provider step execution stubbed behind a port** (the real ACP execution is Milestone 3). + +**Architecture:** A `WorkflowEngine` service orchestrates the Milestone-1 event store + projections. It emits the workflow events M1 already projects. The seam to the outside world is a `StepExecutor` port: M2 ships a deterministic stub; M3 ships the real provider-backed implementation. M2's engine runs pipelines as in-process Effect fibers; durable crash-recovery of in-flight runs is Milestone 3. Approval gates park on an in-memory `ApprovalGate` (Deferred registry); durable approval persistence is also M3. + +**Tech Stack:** TypeScript, Effect 4 (beta), `effect/Schema`, `effect/unstable/sql`, `@effect/vitest`. Depends on Milestone 1 (`WorkflowEventStore`, `WorkflowProjectionPipeline`, `WorkflowReadModel`, `workflow.ts` contracts, `workflowFile.ts` linter). + +**Spec:** `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md` (§7.2 entry, §7.3 approval, §7.5 routing/tokens, §7.6 drag, §7.7 concurrency). + +**Out of scope (later milestones):** real provider dispatch + worktree lease + setup-run gate + crash recovery (M3); ticket checkpoints/diff (M4); RPC + UI (M5). If you find yourself calling `ProviderService` or creating worktrees, stop — that's M3. + +--- + +## File Structure + +**Create:** +- `apps/server/src/persistence/Migrations/0YY_WorkflowTicketToken.ts` — add `current_lane_entry_token` to `projection_ticket`. +- `apps/server/src/workflow/Services/WorkflowIds.ts` + `Layers/WorkflowIds.ts` — id/token generation seam. +- `apps/server/src/workflow/Services/WorkflowEventCommitter.ts` + `Layers/WorkflowEventCommitter.ts` — append-then-project helper. +- `apps/server/src/workflow/Services/BoardRegistry.ts` + `Layers/BoardRegistry.ts` — parsed workflow definitions per board. +- `apps/server/src/workflow/Services/StepExecutor.ts` + `Layers/StubStepExecutor.ts` — the M3 seam + a test/dev stub. +- `apps/server/src/workflow/Services/ApprovalGate.ts` + `Layers/ApprovalGate.ts` — in-memory approval parking. +- `apps/server/src/workflow/Services/WorkflowEngine.ts` + `Layers/WorkflowEngine.ts` — the state machine. +- Tests colocated as `*.test.ts`, plus `apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts`. +- `apps/server/src/workflow/WorkflowEngineLive.ts` — aggregated M2 layer. + +**Modify:** +- `apps/server/src/persistence/Migrations.ts` — register the token migration. +- `packages/contracts/src/workflow.ts` — add `StepOutcome` schema (Task 5). + +--- + +## Task 1: Migration — current lane-entry token on tickets + +**Files:** +- Create: `apps/server/src/persistence/Migrations/0YY_WorkflowTicketToken.ts` +- Modify: `apps/server/src/persistence/Migrations.ts` +- Test: `apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("ticket token migration", (it) => { + it.effect("projection_ticket has current_lane_entry_token", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_ticket) + `; + assert.isTrue(cols.some((c) => c.name === "current_lane_entry_token")); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEngine.token-migration.test.ts` +Expected: FAIL — column absent. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// apps/server/src/persistence/Migrations/0YY_WorkflowTicketToken.ts +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_ticket ADD COLUMN current_lane_entry_token TEXT`; +}); +``` + +Register it in `apps/server/src/persistence/Migrations.ts` (import + append to `migrationEntries` as the next integer after Milestone 1's migration): + +```typescript +import Migration00YYWorkflowTicketToken from "./Migrations/0YY_WorkflowTicketToken.ts"; +// ... + [YY, "WorkflowTicketToken", Migration00YYWorkflowTicketToken], +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEngine.token-migration.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/persistence/Migrations/0YY_WorkflowTicketToken.ts apps/server/src/persistence/Migrations.ts apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts +git commit -m "feat(workflow): add current_lane_entry_token to projection_ticket" +``` + +Also extend the projection pipeline (M1's `WorkflowProjectionPipeline`) to set this column. In `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts`, update the `TicketMovedToLane` case to also write the token: + +```typescript +case "TicketMovedToLane": { + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, status = 'idle', + current_lane_entry_token = ${event.payload.laneEntryToken}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; +} +``` + +Add a quick assertion to M1's projection test (or a new test) that the token column is populated after `TicketMovedToLane`, then commit: + +```bash +git add apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts +git commit -m "feat(workflow): project current lane-entry token onto tickets" +``` + +--- + +## Task 2: WorkflowIds seam (id + token generation) + +Inject id/token creation so the engine is deterministic in tests. The Live layer uses the repo's existing id utility; a Deterministic layer is provided for tests. + +**Files:** +- Create: `apps/server/src/workflow/Services/WorkflowIds.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowIds.ts` +- Test: `apps/server/src/workflow/Layers/WorkflowIds.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowIds.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer(DeterministicWorkflowIds); + +layer("DeterministicWorkflowIds", (it) => { + it.effect("produces stable, prefixed, incrementing ids", () => + Effect.gen(function* () { + const ids = yield* WorkflowIds; + assert.equal(yield* ids.ticketId(), "ticket-1"); + assert.equal(yield* ids.ticketId(), "ticket-2"); + assert.equal(yield* ids.token(), "token-1"); + assert.equal(yield* ids.stepRunId(), "steprun-1"); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowIds.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the interface** + +```typescript +// apps/server/src/workflow/Services/WorkflowIds.ts +import type { + LaneEntryToken, PipelineRunId, StepRunId, TicketId, WorkflowEventId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowIdsShape { + readonly ticketId: () => Effect.Effect; + readonly pipelineRunId: () => Effect.Effect; + readonly stepRunId: () => Effect.Effect; + readonly eventId: () => Effect.Effect; + readonly token: () => Effect.Effect; +} + +export class WorkflowIds extends Context.Service()( + "t3/workflow/Services/WorkflowIds", +) {} +``` + +- [ ] **Step 4: Write the layers** + +```typescript +// apps/server/src/workflow/Layers/WorkflowIds.ts +import { + LaneEntryToken, PipelineRunId, StepRunId, TicketId, WorkflowEventId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { WorkflowIds, type WorkflowIdsShape } from "../Services/WorkflowIds.ts"; + +/** Deterministic ids for tests: monotonically increasing per prefix. */ +export const DeterministicWorkflowIds = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const counters = yield* Ref.make>({}); + const next = (prefix: string) => + Ref.modify(counters, (c) => { + const n = (c[prefix] ?? 0) + 1; + return [`${prefix}-${n}`, { ...c, [prefix]: n }] as const; + }); + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + } satisfies WorkflowIdsShape; + }), +); + +/** + * Production ids. Use the repo's existing id utility (search for the helper used + * by orchestration, e.g. a `newId`/ULID util in packages/shared or apps/server); + * replace `genId(prefix)` below with that call. + */ +export const WorkflowIdsLive = Layer.effect( + WorkflowIds, + Effect.sync(() => { + const genId = (prefix: string): string => `${prefix}-${crypto.randomUUID()}`; + return { + ticketId: () => Effect.sync(() => TicketId.make(genId("ticket"))), + pipelineRunId: () => Effect.sync(() => PipelineRunId.make(genId("pipelinerun"))), + stepRunId: () => Effect.sync(() => StepRunId.make(genId("steprun"))), + eventId: () => Effect.sync(() => WorkflowEventId.make(genId("evt"))), + token: () => Effect.sync(() => LaneEntryToken.make(genId("token"))), + } satisfies WorkflowIdsShape; + }), +); +``` + +> Note: if the repo has a canonical id generator (check how `ThreadId`/`CommandId` values are minted server-side), use it in `WorkflowIdsLive` instead of `crypto.randomUUID()`. + +- [ ] **Step 5: Run test + commit** + +Run: `pnpm --filter @t3tools/server test -- WorkflowIds.test.ts` → PASS. + +```bash +git add apps/server/src/workflow/Services/WorkflowIds.ts apps/server/src/workflow/Layers/WorkflowIds.ts apps/server/src/workflow/Layers/WorkflowIds.test.ts +git commit -m "feat(workflow): add WorkflowIds id/token seam" +``` + +--- + +## Task 3: WorkflowEventCommitter (append + project) + +A single path the engine uses to persist an event and update projections, mirroring how orchestration appends then projects. (M3 will extend this to also push to subscribers; M2 keeps it append+project.) + +**Files:** +- Create: `apps/server/src/workflow/Services/WorkflowEventCommitter.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowEventCommitter.ts` +- Test: `apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEventCommitter", (it) => { + it.effect("appends and projects in one call", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* committer.commit({ + type: "TicketCreated", eventId: "e1" as never, ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + const rows = yield* sql<{ readonly title: string }>` + SELECT title FROM projection_ticket WHERE ticket_id = 't-1'`; + assert.equal(rows[0]?.title, "X"); + }), + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEventCommitter.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Write the interface** + +```typescript +// apps/server/src/workflow/Services/WorkflowEventCommitter.ts +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventInput } from "./WorkflowEventStore.ts"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowEventCommitterShape { + readonly commit: ( + event: WorkflowEventInput, + ) => Effect.Effect; +} + +export class WorkflowEventCommitter extends Context.Service< + WorkflowEventCommitter, + WorkflowEventCommitterShape +>()("t3/workflow/Services/WorkflowEventCommitter") {} +``` + +- [ ] **Step 4: Write the layer** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEventCommitter.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; + +const make = Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const pipeline = yield* WorkflowProjectionPipeline; + + const commit: WorkflowEventCommitterShape["commit"] = (event) => + Effect.gen(function* () { + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + }); + + return { commit } satisfies WorkflowEventCommitterShape; +}); + +export const WorkflowEventCommitterLive = Layer.effect(WorkflowEventCommitter, make); +``` + +- [ ] **Step 5: Run test + commit** + +Run → PASS. + +```bash +git add apps/server/src/workflow/Services/WorkflowEventCommitter.ts apps/server/src/workflow/Layers/WorkflowEventCommitter.ts apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts +git commit -m "feat(workflow): add WorkflowEventCommitter (append+project)" +``` + +--- + +## Task 4: BoardRegistry (parsed workflow definitions) + +Holds the validated `WorkflowDefinition` per board so the engine can resolve lanes and routing. Parsing/linting reuses Milestone 1. + +**Files:** +- Create: `apps/server/src/workflow/Services/BoardRegistry.ts` +- Create: `apps/server/src/workflow/Layers/BoardRegistry.ts` +- Test: `apps/server/src/workflow/Layers/BoardRegistry.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/BoardRegistry.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; + +const layer = it.layer(BoardRegistryLive); + +const def = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "impl", name: "Impl", entry: "auto", + pipeline: [{ key: "code", type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, instruction: "do it" }], + on: { success: "done" } }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +layer("BoardRegistry", (it) => { + it.effect("registers a definition and resolves lanes", () => + Effect.gen(function* () { + const reg = yield* BoardRegistry; + yield* reg.register("b-1" as never, def); + const lane = yield* reg.getLane("b-1" as never, "impl" as never); + assert.equal(lane?.entry, "auto"); + assert.equal(lane?.pipeline?.length, 1); + }), + ); + + it.effect("rejects an invalid definition", () => + Effect.gen(function* () { + const reg = yield* BoardRegistry; + const result = yield* reg.register("b-2" as never, { + name: "bad", lanes: [{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }], + }).pipe(Effect.either); + assert.equal(result._tag, "Left"); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +Run: `pnpm --filter @t3tools/server test -- BoardRegistry.test.ts` + +- [ ] **Step 3: Write the interface** + +```typescript +// apps/server/src/workflow/Services/BoardRegistry.ts +import type { BoardId, LaneKey, WorkflowDefinition, WorkflowLane } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class BoardRegistryError extends Schema.TaggedError()( + "BoardRegistryError", + { message: Schema.String }, +) {} + +export interface BoardRegistryShape { + readonly register: ( + boardId: BoardId, def: unknown, + ) => Effect.Effect; + readonly getDefinition: ( + boardId: BoardId, + ) => Effect.Effect; + readonly getLane: ( + boardId: BoardId, laneKey: LaneKey, + ) => Effect.Effect; +} + +export class BoardRegistry extends Context.Service()( + "t3/workflow/Services/BoardRegistry", +) {} +``` + +- [ ] **Step 4: Write the layer** + +```typescript +// apps/server/src/workflow/Layers/BoardRegistry.ts +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { + BoardRegistry, BoardRegistryError, type BoardRegistryShape, +} from "../Services/BoardRegistry.ts"; +import { lintWorkflowDefinition } from "../workflowFile.ts"; + +const make = Effect.gen(function* () { + const store = yield* Ref.make>(new Map()); + + const register: BoardRegistryShape["register"] = (boardId, raw) => + Effect.gen(function* () { + const def = yield* Schema.decodeUnknown(WorkflowDefinition)(raw).pipe( + Effect.mapError((cause) => + new BoardRegistryError({ message: `Invalid workflow: ${String(cause)}` })), + ); + // In M2 the lint context permits all providers/files; M5 wires real checks. + const errors = lintWorkflowDefinition(def, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }); + if (errors.length > 0) { + return yield* Effect.fail(new BoardRegistryError({ + message: `Workflow lint failed: ${errors.map((e) => e.code).join(", ")}` })); + } + yield* Ref.update(store, (m) => new Map(m).set(boardId, def)); + return def; + }); + + const getDefinition: BoardRegistryShape["getDefinition"] = (boardId) => + Ref.get(store).pipe(Effect.map((m) => m.get(boardId) ?? null)); + + const getLane: BoardRegistryShape["getLane"] = (boardId, laneKey) => + getDefinition(boardId).pipe( + Effect.map((def) => def?.lanes.find((l) => l.key === laneKey) ?? null), + ); + + return { register, getDefinition, getLane } satisfies BoardRegistryShape; +}); + +export const BoardRegistryLive = Layer.effect(BoardRegistry, make); +``` + +- [ ] **Step 5: Run + commit** + +```bash +git add apps/server/src/workflow/Services/BoardRegistry.ts apps/server/src/workflow/Layers/BoardRegistry.ts apps/server/src/workflow/Layers/BoardRegistry.test.ts +git commit -m "feat(workflow): add BoardRegistry for parsed workflow definitions" +``` + +--- + +## Task 5: StepExecutor port + stub, and StepOutcome contract + +The seam M3 fills with real provider execution. M2 provides a stub that returns a scripted outcome so the engine is testable. + +**Files:** +- Modify: `packages/contracts/src/workflow.ts` (add `StepOutcome`) +- Create: `apps/server/src/workflow/Services/StepExecutor.ts` +- Create: `apps/server/src/workflow/Layers/StubStepExecutor.ts` +- Test: `apps/server/src/workflow/Layers/StubStepExecutor.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/StubStepExecutor.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +layerFromStub(); +function layerFromStub() { + const layer = it.layer(makeStubStepExecutor({ default: { _tag: "completed" } })); + layer("StubStepExecutor", (it) => { + it.effect("returns the scripted default outcome", () => + Effect.gen(function* () { + const exec = yield* StepExecutor; + const outcome = yield* exec.execute({ + ticketId: "t-1" as never, boardId: "b-1" as never, + pipelineRunId: "pr-1" as never, stepRunId: "sr-1" as never, + laneEntryToken: "tok-1" as never, + step: { key: "code" as never, type: "agent", + agent: { instance: "claude_main" as never, model: "sonnet" as never }, + instruction: "x" }, + }); + assert.equal(outcome._tag, "completed"); + }), + ); + }); +} +``` + +- [ ] **Step 2: Run → FAIL.** + +Run: `pnpm --filter @t3tools/server test -- StubStepExecutor.test.ts` + +- [ ] **Step 3: Add the StepOutcome contract** + +```typescript +// append to packages/contracts/src/workflow.ts +export const StepOutcome = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("completed") }), + Schema.Struct({ _tag: Schema.Literal("failed"), error: Schema.String }), + Schema.Struct({ _tag: Schema.Literal("awaiting_user"), waitingReason: Schema.String }), +]); +export type StepOutcome = typeof StepOutcome.Type; +``` + +- [ ] **Step 4: Write the interface** + +```typescript +// apps/server/src/workflow/Services/StepExecutor.ts +import type { + BoardId, LaneEntryToken, PipelineRunId, StepOutcome, StepRunId, TicketId, WorkflowStep, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface StepExecutionContext { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly pipelineRunId: PipelineRunId; + readonly stepRunId: StepRunId; + readonly laneEntryToken: LaneEntryToken; + readonly step: WorkflowStep; +} + +export interface StepExecutorShape { + /** Execute one agent step. Approval steps are handled by the engine, not here. */ + readonly execute: (ctx: StepExecutionContext) => Effect.Effect; +} + +export class StepExecutor extends Context.Service()( + "t3/workflow/Services/StepExecutor", +) {} +``` + +- [ ] **Step 5: Write the stub layer** + +```typescript +// apps/server/src/workflow/Layers/StubStepExecutor.ts +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; + +export interface StubScript { + readonly default: StepOutcome; + /** Per-stepKey overrides for targeted tests. */ + readonly byStepKey?: Record; +} + +export const makeStubStepExecutor = (script: StubScript): Layer.Layer => + Layer.succeed(StepExecutor, { + execute: (ctx) => + Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), + } satisfies StepExecutorShape); +``` + +- [ ] **Step 6: Run + commit** + +```bash +git add packages/contracts/src/workflow.ts apps/server/src/workflow/Services/StepExecutor.ts apps/server/src/workflow/Layers/StubStepExecutor.ts apps/server/src/workflow/Layers/StubStepExecutor.test.ts +git commit -m "feat(workflow): add StepExecutor port and stub + StepOutcome contract" +``` + +--- + +## Task 6: ApprovalGate (in-memory parking) + +Parks approval steps until resolved. (M3 makes approvals durable; M2 is in-process.) + +**Files:** +- Create: `apps/server/src/workflow/Services/ApprovalGate.ts` +- Create: `apps/server/src/workflow/Layers/ApprovalGate.ts` +- Test: `apps/server/src/workflow/Layers/ApprovalGate.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/ApprovalGate.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; + +const layer = it.layer(ApprovalGateLive); + +layer("ApprovalGate", (it) => { + it.effect("await resolves once resolve is called", () => + Effect.gen(function* () { + const gate = yield* ApprovalGate; + const fiber = yield* Effect.fork(gate.await("sr-1" as never)); + yield* Effect.yieldNow(); + yield* gate.resolve("sr-1" as never, true); + const approved = yield* fiber.await.pipe(Effect.flatMap((e) => e)); + assert.equal(approved, true); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +Run: `pnpm --filter @t3tools/server test -- ApprovalGate.test.ts` + +- [ ] **Step 3: Write the interface** + +```typescript +// apps/server/src/workflow/Services/ApprovalGate.ts +import type { StepRunId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ApprovalGateShape { + readonly await: (stepRunId: StepRunId) => Effect.Effect; + readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect; +} + +export class ApprovalGate extends Context.Service()( + "t3/workflow/Services/ApprovalGate", +) {} +``` + +- [ ] **Step 4: Write the layer** + +```typescript +// apps/server/src/workflow/Layers/ApprovalGate.ts +import type { StepRunId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { ApprovalGate, type ApprovalGateShape } from "../Services/ApprovalGate.ts"; + +const make = Effect.gen(function* () { + const pending = yield* Ref.make>>(new Map()); + + const ensure = (id: string) => + Effect.gen(function* () { + const map = yield* Ref.get(pending); + const existing = map.get(id); + if (existing) return existing; + const d = yield* Deferred.make(); + yield* Ref.update(pending, (m) => new Map(m).set(id, d)); + return d; + }); + + const awaitFn: ApprovalGateShape["await"] = (stepRunId) => + ensure(stepRunId as string).pipe(Effect.flatMap(Deferred.await)); + + const resolve: ApprovalGateShape["resolve"] = (stepRunId, approved) => + ensure(stepRunId as string).pipe( + Effect.flatMap((d) => Deferred.succeed(d, approved)), + Effect.asVoid, + ); + + return { await: awaitFn, resolve } satisfies ApprovalGateShape; +}); + +export const ApprovalGateLive = Layer.effect(ApprovalGate, make); +``` + +- [ ] **Step 5: Run + commit** + +```bash +git add apps/server/src/workflow/Services/ApprovalGate.ts apps/server/src/workflow/Layers/ApprovalGate.ts apps/server/src/workflow/Layers/ApprovalGate.test.ts +git commit -m "feat(workflow): add in-memory ApprovalGate" +``` + +--- + +## Task 7: WorkflowEngine — create ticket, run pipeline, route + +The state machine. Public API: `createTicket`, `moveTicket` (manual drag), `runLane` (manual entry), `resolveApproval`. Internally it runs a lane's pipeline and routes on completion with token guarding. + +**Files:** +- Create: `apps/server/src/workflow/Services/WorkflowEngine.ts` +- Create: `apps/server/src/workflow/Layers/WorkflowEngine.ts` + +- [ ] **Step 1: Write the interface** + +```typescript +// apps/server/src/workflow/Services/WorkflowEngine.ts +import type { BoardId, LaneKey, StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowEngineShape { + readonly createTicket: (input: { + readonly boardId: BoardId; readonly title: string; + readonly description?: string; readonly initialLane: LaneKey; + }) => Effect.Effect; + + /** Manual drag: move a ticket to a lane, supersede any in-flight run, trigger entry. */ + readonly moveTicket: ( + ticketId: TicketId, toLane: LaneKey, + ) => Effect.Effect; + + /** Manually start a lane's pipeline (for `entry: manual` lanes with a pipeline). */ + readonly runLane: (ticketId: TicketId) => Effect.Effect; + + readonly resolveApproval: ( + stepRunId: StepRunId, approved: boolean, + ) => Effect.Effect; +} + +export class WorkflowEngine extends Context.Service()( + "t3/workflow/Services/WorkflowEngine", +) {} +``` + +- [ ] **Step 2: Write the layer (the state machine)** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEngine.ts +import type { LaneKey, TicketId, WorkflowLane, WorkflowStep } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const nowIso = () => new Date().toISOString(); // server code may use Date; only workflow-script sandbox forbids it + +const make = Effect.gen(function* () { + const ids = yield* WorkflowIds; + const committer = yield* WorkflowEventCommitter; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const executor = yield* StepExecutor; + const approvals = yield* ApprovalGate; + + // Read the ticket's current lane-entry token to guard stale routing. + const currentToken = (ticketId: TicketId) => + read.getTicketDetail(ticketId).pipe( + Effect.map((d) => (d?.ticket as { current_lane_entry_token?: string } | undefined) + ?.current_lane_entry_token ?? null), + ); + + // Run a lane's pipeline for a ticket, bound to `laneEntryToken`. + const runPipeline = ( + ticketId: TicketId, boardId: string, lane: WorkflowLane, laneEntryToken: string, + ): Effect.Effect => + Effect.gen(function* () { + const steps = lane.pipeline ?? []; + if (steps.length === 0) return; // nothing to run; lane just holds the ticket + const pipelineRunId = yield* ids.pipelineRunId(); + yield* commit({ type: "PipelineStarted", ticketId, + payload: { pipelineRunId, laneKey: lane.key, laneEntryToken } }); + + let result: "success" | "failure" | "blocked" = "success"; + for (const step of steps) { + const outcome = yield* runStep(ticketId, boardId, pipelineRunId, step, laneEntryToken); + if (outcome === "failed") { result = "failure"; break; } + if (outcome === "blocked") { result = "blocked"; break; } + } + + yield* commit({ type: "PipelineCompleted", ticketId, + payload: { pipelineRunId, result } }); + + // Token guard: only route if the ticket is still on this lane-entry token. + const token = yield* currentToken(ticketId); + if (token !== laneEntryToken) return; // superseded by a manual move + yield* route(ticketId, boardId, lane, result); + }).pipe(Effect.catchAll(() => Effect.void)); // pipeline errors never crash the engine + + const runStep = ( + ticketId: TicketId, boardId: string, pipelineRunId: string, step: WorkflowStep, + laneEntryToken: string, + ): Effect.Effect<"completed" | "failed" | "blocked", never> => + Effect.gen(function* () { + const stepRunId = yield* ids.stepRunId(); + yield* commit({ type: "StepStarted", ticketId, + payload: { pipelineRunId, stepRunId, stepKey: step.key, stepType: step.type } }); + + if (step.type === "approval") { + yield* commit({ type: "StepAwaitingUser", ticketId, + payload: { stepRunId, waitingReason: step.prompt ?? "Approval required" } }); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ type: "StepFailed", ticketId, + payload: { stepRunId, error: "rejected" } }); + return "failed"; + } + yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); + return "completed"; + } + + // agent step → delegate to the executor port (real impl is M3) + const outcome = yield* executor.execute({ + ticketId, boardId: boardId as never, pipelineRunId: pipelineRunId as never, + stepRunId, laneEntryToken: laneEntryToken as never, step }); + if (outcome._tag === "awaiting_user") { + yield* commit({ type: "StepAwaitingUser", ticketId, + payload: { stepRunId, waitingReason: outcome.waitingReason } }); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + // M2: treat any resolution as completion; M3 implements re-dispatch semantics. + yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); + return approved ? "completed" : "failed"; + } + if (outcome._tag === "failed") { + yield* commit({ type: "StepFailed", ticketId, + payload: { stepRunId, error: outcome.error } }); + return "failed"; + } + yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); + return "completed"; + }); + + const route = ( + ticketId: TicketId, boardId: string, lane: WorkflowLane, + result: "success" | "failure" | "blocked", + ): Effect.Effect => + Effect.gen(function* () { + const target = lane.on?.[result]; + if (!target) { + if (result !== "success") { + yield* commit({ type: "TicketBlocked", ticketId, + payload: { reason: `pipeline ${result} with no route` } }); + } + return; + } + yield* moveToLane(ticketId, boardId, target, "routed"); + }); + + // Move a ticket to a lane (new token), then trigger the lane's entry behavior. + const moveToLane = ( + ticketId: TicketId, boardId: string, toLane: LaneKey, + reason: "manual" | "routed" | "initial", + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* ids.token(); + yield* commit({ type: "TicketMovedToLane", ticketId, + payload: { toLane, laneEntryToken: token, reason } }); + const lane = yield* registry.getLane(boardId as never, toLane); + if (lane && lane.entry === "auto") { + // fork so chained auto lanes don't deepen the stack unbounded + yield* Effect.forkDaemon(runPipeline(ticketId, boardId, lane, token)); + } + }); + + // commit wrapper that injects eventId + occurredAt + function commit(e: { + type: string; ticketId: TicketId; payload: unknown; + }): Effect.Effect { + return Effect.gen(function* () { + const eventId = yield* ids.eventId(); + yield* committer.commit({ ...e, eventId, occurredAt: nowIso() } as never); + }).pipe(Effect.catchAll(() => Effect.void)); + } + + const createTicket: WorkflowEngineShape["createTicket"] = (input) => + Effect.gen(function* () { + const ticketId = yield* ids.ticketId(); + const eventId = yield* ids.eventId(); + yield* committer.commit({ type: "TicketCreated", eventId, ticketId, + occurredAt: nowIso() as never, + payload: { boardId: input.boardId, title: input.title as never, + laneKey: input.initialLane, description: input.description } as never }); + yield* moveToLane(ticketId, input.boardId, input.initialLane, "initial"); + return ticketId; + }); + + const moveTicket: WorkflowEngineShape["moveTicket"] = (ticketId, toLane) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + const boardId = detail?.ticket.boardId; + if (!boardId) return; + yield* moveToLane(ticketId, boardId, toLane, "manual"); + }); + + const runLane: WorkflowEngineShape["runLane"] = (ticketId) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (!detail) return; + const boardId = detail.ticket.boardId; + const laneKey = detail.ticket.currentLaneKey as never; + const lane = yield* registry.getLane(boardId as never, laneKey); + const token = yield* currentToken(ticketId); + if (lane && token) { + yield* Effect.forkDaemon(runPipeline(ticketId, boardId, lane, token)); + } + }); + + const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => + approvals.resolve(stepRunId, approved); + + return { createTicket, moveTicket, runLane, resolveApproval } satisfies WorkflowEngineShape; +}); + +export const WorkflowEngineLayer = Layer.effect(WorkflowEngine, make); +``` + +> Concurrency note: this task omits the `maxConcurrentTickets` gate — Task 9 adds it. Routing emits `TicketMovedToLane` with `reason: "routed"` (the unused `TicketRouted` event from M1 is reserved/deprecated). `currentToken` reads the projection column added in Task 1; adjust the property access to the exact casing `WorkflowReadModel` returns (extend `TicketRow` to expose `currentLaneEntryToken` — see Task 8). + +- [ ] **Step 3: Extend WorkflowReadModel TicketRow** to expose the token (so `currentToken` is typed): + +In `apps/server/src/workflow/Services/WorkflowReadModel.ts`, add `currentLaneEntryToken: string | null` to `TicketRow`; in `Layers/WorkflowReadModel.ts`, add `current_lane_entry_token AS "currentLaneEntryToken"` to the ticket SELECTs. Update `currentToken` in the engine to read `detail.ticket.currentLaneEntryToken`. + +- [ ] **Step 4: Commit (tests come in Task 8)** + +```bash +git add apps/server/src/workflow/Services/WorkflowEngine.ts apps/server/src/workflow/Layers/WorkflowEngine.ts apps/server/src/workflow/Services/WorkflowReadModel.ts apps/server/src/workflow/Layers/WorkflowReadModel.ts +git commit -m "feat(workflow): add WorkflowEngine state machine (create/run/route/drag/approve)" +``` + +--- + +## Task 8: Integration test — 3-lane flow, approval pause/resume, drag supersede + +**Files:** +- Create: `apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts` + +- [ ] **Step 1: Write the test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const def = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "impl", name: "Impl", entry: "auto", + pipeline: [{ key: "code", type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, instruction: "do it" }], + on: { success: "done", failure: "needs" } }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const baseLayer = (executor: Layer.Layer) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const successLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }) as never), +); + +successLayer("WorkflowEngine integration (success path)", (it) => { + it.effect("auto lane runs the pipeline and routes to done", () => + Effect.gen(function* () { + const reg = yield* BoardRegistry; + yield* reg.register("b-1" as never, def); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-1" as never, title: "Export", initialLane: "impl" as never }); + + // allow forked auto-pipeline + chained routing to settle + yield* Effect.sleep("50 millis"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + assert.equal(detail?.steps.some((s) => s.status === "completed"), true); + }), + ); +}); + +const failLayer = it.layer( + baseLayer(makeStubStepExecutor({ + default: { _tag: "failed", error: "boom" } }) as never), +); + +failLayer("WorkflowEngine integration (failure path)", (it) => { + it.effect("failed step routes to the failure lane", () => + Effect.gen(function* () { + const reg = yield* BoardRegistry; + yield* reg.register("b-1" as never, def); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const ticketId = yield* engine.createTicket({ + boardId: "b-1" as never, title: "Y", initialLane: "impl" as never }); + yield* Effect.sleep("50 millis"); + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); +}); +``` + +- [ ] **Step 2: Run to verify** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEngine.integration.test.ts` +Expected: PASS (success routes to `done`, failure routes to `needs`). If timing is flaky, prefer awaiting a deterministic signal (e.g. poll the read model in a `Effect.repeat` with a short schedule) over `Effect.sleep`. + +- [ ] **Step 3: Add an approval pause/resume test** + +```typescript +// append to the integration test file +const approvalDef = { + name: "wf", + lanes: [ + { key: "review", name: "Review", entry: "auto", + pipeline: [{ key: "ok", type: "approval", prompt: "Approve?" }], + on: { success: "done", failure: "needs" } }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +successLayer("WorkflowEngine approval gate", (it) => { + it.effect("parks on approval then routes on approve", () => + Effect.gen(function* () { + const reg = yield* BoardRegistry; + yield* reg.register("b-2" as never, approvalDef); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const ticketId = yield* engine.createTicket({ + boardId: "b-2" as never, title: "Approve me", initialLane: "review" as never }); + yield* Effect.sleep("30 millis"); + + let detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.status, "waiting_on_user"); + const stepRunId = detail!.steps[0]!.stepRunId; + + yield* engine.resolveApproval(stepRunId as never, true); + yield* Effect.sleep("30 millis"); + detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }), + ); +}); +``` + +- [ ] **Step 4: Run + commit** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEngine.integration.test.ts` → PASS. + +```bash +git add apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +git commit -m "test(workflow): integration tests for pipeline routing and approval gate" +``` + +--- + +## Task 9: Per-board concurrency gate + +Cap concurrently-running tickets per board with a semaphore keyed by board. + +**Files:** +- Modify: `apps/server/src/workflow/Layers/WorkflowEngine.ts` +- Test: `apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts +// Register a board whose definition sets settings.maxConcurrentTickets = 1, create +// two tickets that each enter an auto lane whose stub executor blocks on a latch, +// and assert only one pipeline is `running` at a time (the second ticket stays +// `idle`/queued until the first completes). Use a stub executor that waits on a +// Deferred you control to hold the first pipeline open. +// +// (Full harness mirrors Task 8's layer wiring; assert via read.listTickets statuses.) +``` + +- [ ] **Step 2: Run → FAIL** (no gating yet; both run). + +- [ ] **Step 3: Implement the gate** + +In `make` (WorkflowEngine layer), build a per-board semaphore map and acquire a permit around `runPipeline`: + +```typescript +import * as Effect from "effect/Effect"; +// inside make(): +const boardSemaphores = yield* Ref.make>(new Map()); + +const semaphoreFor = (boardId: string, permits: number) => + Effect.gen(function* () { + const map = yield* Ref.get(boardSemaphores); + const existing = map.get(boardId); + if (existing) return existing; + const sem = yield* Effect.makeSemaphore(permits); + yield* Ref.update(boardSemaphores, (m) => new Map(m).set(boardId, sem)); + return sem; + }); +``` + +Wrap the body of `runPipeline` so it acquires one permit for its duration: + +```typescript +const def = yield* registry.getDefinition(boardId as never); +const permits = def?.settings?.maxConcurrentTickets ?? 3; +const sem = yield* semaphoreFor(boardId, permits); +yield* sem.withPermits(1)(/* existing pipeline body Effect */); +``` + +> Verify the exact API (`Effect.makeSemaphore(n)` returns a `Semaphore` with `withPermits`) against the installed Effect beta; mirror any existing semaphore use found in the codebase. Add `import * as Ref from "effect/Ref"` if not already imported. + +- [ ] **Step 4: Run + commit** + +Run: `pnpm --filter @t3tools/server test -- WorkflowEngine.concurrency.test.ts` → PASS. + +```bash +git add apps/server/src/workflow/Layers/WorkflowEngine.ts apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts +git commit -m "feat(workflow): per-board concurrency gate for running tickets" +``` + +--- + +## Task 10: Aggregate WorkflowEngineLive + typecheck + +**Files:** +- Create: `apps/server/src/workflow/WorkflowEngineLive.ts` +- Test: reuse Task 8 integration layer wiring (no new test needed beyond a typecheck). + +- [ ] **Step 1: Write the aggregated layer** + +```typescript +// apps/server/src/workflow/WorkflowEngineLive.ts +import * as Layer from "effect/Layer"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +/** + * Milestone-2 engine, minus the StepExecutor (provide a real one in M3 or the + * stub in tests) and minus SqlClient/Migrations (provided by the server runtime). + */ +export const WorkflowEngineCoreLive = WorkflowEngineLayer.pipe( + Layer.provide(WorkflowEventCommitterLive), + Layer.provide(BoardRegistryLive), + Layer.provide(ApprovalGateLive), + Layer.provide(WorkflowIdsLive), + Layer.provide(WorkflowFoundationLive), +); +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @t3tools/server typecheck` +Expected: PASS (no errors under `apps/server/src/workflow/`). + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/workflow/WorkflowEngineLive.ts +git commit -m "feat(workflow): aggregate WorkflowEngine core layer" +``` + +--- + +## Milestone Done — Definition of Done + +- [ ] `pnpm --filter @t3tools/server test -- workflow` is green (foundation + engine). +- [ ] `pnpm --filter @t3tools/server typecheck` passes. +- [ ] With the stub executor, a ticket created in an `auto` lane runs its pipeline and routes (success/failure) to the correct next lane; an approval step parks the ticket `waiting_on_user` and resumes on `resolveApproval`; a manual move supersedes an in-flight pipeline's routing; concurrency is capped per board. +- [ ] **Next:** Milestone 3 replaces `StubStepExecutor` with a real provider-backed `StepExecutor` (worktree lease, setup-run gate, provider-dispatch outbox, terminal-state detection, durable approvals, crash recovery). + +--- + +## Notes for the implementer + +- **Forked pipelines:** `runPipeline` is forked (`forkDaemon`) so chained `auto` lanes don't grow the stack and so `createTicket`/`moveTicket` return promptly. Tests settle with a short sleep or a poll loop — prefer polling the read model for determinism. +- **Token guard is the supersede mechanism:** a completing pipeline checks the ticket's `current_lane_entry_token`; if a manual `moveTicket` changed it, the stale pipeline records `PipelineCompleted` but does **not** route. (M3 adds active interruption of the in-flight provider turn; M2 just refuses to route.) +- **In-process only:** M2 has no crash recovery; killing the server loses in-flight pipeline fibers. That's intentional — M3's durable outbox/lease makes runs recoverable. +- **`as never` casts** in tests are fixture shortcuts against branded IDs; production call sites construct IDs via `WorkflowIds`. +- **Effect API drift:** confirm `Effect.makeSemaphore`, `Effect.forkDaemon`, `Deferred`, and `Ref` import paths against the installed beta; mirror existing usages where they differ. From 262db315acc567d7a05775f876fced8d829dcde6 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 00:21:21 -0400 Subject: [PATCH 005/295] docs: add workflow boards v1 Milestone 3 (durable execution) plan Replaces M2's stub executor with a real provider-backed StepExecutor plus the durability machinery: fenced worktree lease, durable setup-run gate, provider-dispatch outbox confirmed from persisted provider progress (not command intake), terminal-state reader, durable approvals vs volatile provider questions, and startup recovery. Built on stubbable ports so orchestration logic is unit-tested and real-service bridging is isolated; includes a restart integration test proving no duplicate dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...workflow-boards-v1-m3-durable-execution.md | 1089 +++++++++++++++++ 1 file changed, 1089 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md new file mode 100644 index 00000000000..dbc157ab9b9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md @@ -0,0 +1,1089 @@ +# Workflow Boards v1 — Milestone 3: Durable Agent Execution — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Milestone 2's `StubStepExecutor` with a real provider-backed executor that runs an agent step in the ticket's worktree, plus the durability machinery the spec requires: a fenced **worktree lease**, a gated **setup run**, a **provider-dispatch outbox** confirmed from persisted provider progress, terminal-state detection from projections, durable workflow approvals vs. volatile provider questions, and crash recovery. + +**Architecture:** A `RealStepExecutor` (provides the `StepExecutor` port from M2) orchestrates existing t3code services: `GitWorkflowService`/VCS (worktree), `ProjectSetupScriptRunner` + `TerminalManager` (setup gate), `ProviderService` (run the turn), and `ProjectionTurnRepository` + `CheckpointStore` (terminal-state detection). Two new durable tables back recovery: `workflow_dispatch_outbox` and `worktree_lease` (+ `workflow_setup_run` from §7.2.1). The dispatch worker drives `ProviderService` directly (spec §7.8 option a) and confirms from persisted `projection_turns.state`, never a live stream. + +**Tech Stack:** TypeScript, Effect 4 (beta), `effect/unstable/sql`, `@effect/vitest`. Depends on M1 + M2. + +**Spec:** `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md` (§7.1 lease, §7.2/7.2.1 setup, §7.3 waits, §7.8 dispatch, §7.9 recovery). + +**Reference signatures (verified from the codebase — confirm exact shapes when implementing):** +- `ProviderService.startSession(threadId, ProviderSessionStartInput)` and `sendTurn(ProviderSendTurnInput)`, `interruptTurn(...)`, `respondToUserInput(...)`, `respondToRequest(...)` — `apps/server/src/provider/Services/ProviderService.ts`. +- Terminal turn state: `projection_turns.state ∈ {pending,running,interrupted,completed,error}` + `completedAt`; read via `ProjectionTurnRepository.getByTurnId(...)`. +- `ProjectSetupScriptRunner.runForThread(input) → { status: "started", scriptId, terminalId }`; await completion via `TerminalManager.subscribe` watching `TerminalExitedEvent { exitCode }`. +- `GitWorkflowService.createWorktree(input) → VcsCreateWorktreeResult { worktree: { path, refName } }`. +- `Effect.makeSemaphore` / `Semaphore.make(1).withPermits(1)(effect)`, `Deferred`, `Ref` — confirmed idioms. + +**Out of scope:** ticket checkpoints/diff (M4), RPC + UI (M5). + +--- + +## File Structure + +**Create (migrations):** +- `apps/server/src/persistence/Migrations/0AA_WorkflowLease.ts` — `worktree_lease`. +- `apps/server/src/persistence/Migrations/0BB_WorkflowDispatchOutbox.ts` — `workflow_dispatch_outbox`. +- `apps/server/src/persistence/Migrations/0CC_WorkflowSetupRun.ts` — `workflow_setup_run`. + +**Create (services + layers, each with a colocated test):** +- `workflow/Services/WorktreeLeaseService.ts` + `Layers/WorktreeLeaseService.ts` +- `workflow/Services/SetupRunService.ts` + `Layers/SetupRunService.ts` +- `workflow/Services/TurnStateReader.ts` + `Layers/TurnStateReader.ts` +- `workflow/Services/ProviderDispatchOutbox.ts` + `Layers/ProviderDispatchOutbox.ts` +- `workflow/Layers/RealStepExecutor.ts` (provides `StepExecutor`) +- `workflow/Services/WorkflowRecovery.ts` + `Layers/WorkflowRecovery.ts` +- `workflow/Layers/MockAcpProvider.ts` (test double for `ProviderService`) +- `workflow/WorkflowRuntimeLive.ts` — aggregates M2 engine + RealStepExecutor + recovery. + +**Modify:** +- `apps/server/src/persistence/Migrations.ts` — register the three migrations. +- `packages/contracts/src/workflow.ts` — add lease/dispatch/setup row + status schemas and the new workflow events (`ProviderDispatchRequested`, `ProviderDispatchConfirmed`, `WorktreeLeaseAcquired/Released`, `SetupStarted/Completed/Failed`). + +--- + +## Task 1: Migrations — lease, dispatch outbox, setup run + +**Files:** +- Create the three migration files; modify `Migrations.ts`. +- Test: `apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("M3 migrations", (it) => { + it.effect("creates lease, dispatch outbox, and setup run tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type='table' + AND name IN ('worktree_lease','workflow_dispatch_outbox','workflow_setup_run')`; + assert.equal(rows.length, 3); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +Run: `pnpm --filter @t3tools/server test -- WorktreeLeaseService.migration.test.ts` + +- [ ] **Step 3: Write the migrations** + +```typescript +// apps/server/src/persistence/Migrations/0AA_WorkflowLease.ts +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS worktree_lease ( + worktree_ref TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL, -- 'step' | 'user' + owner_id TEXT NOT NULL, -- stepRunId or 'user' + fence_token INTEGER NOT NULL, + acquired_at TEXT NOT NULL, + expires_at TEXT NOT NULL + )`; +}); +``` + +```typescript +// apps/server/src/persistence/Migrations/0BB_WorkflowDispatchOutbox.ts +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_dispatch_outbox ( + dispatch_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + step_run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + command_id TEXT NOT NULL, + message_id TEXT NOT NULL, + provider_instance TEXT NOT NULL, + model TEXT NOT NULL, + instruction TEXT NOT NULL, + worktree_path TEXT NOT NULL, + status TEXT NOT NULL, -- 'pending' | 'confirmed' + created_at TEXT NOT NULL, + confirmed_at TEXT + )`; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_dispatch_outbox_pending + ON workflow_dispatch_outbox(status)`; +}); +``` + +```typescript +// apps/server/src/persistence/Migrations/0CC_WorkflowSetupRun.ts +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_setup_run ( + setup_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL UNIQUE, + worktree_ref TEXT NOT NULL, + status TEXT NOT NULL, -- 'running' | 'completed' | 'failed' | 'timed_out' + exit_code INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT + )`; +}); +``` + +Register all three in `apps/server/src/persistence/Migrations.ts` (next three integers). + +- [ ] **Step 4: Run → PASS.** **Step 5: Commit.** + +```bash +git add apps/server/src/persistence/Migrations/0AA_WorkflowLease.ts apps/server/src/persistence/Migrations/0BB_WorkflowDispatchOutbox.ts apps/server/src/persistence/Migrations/0CC_WorkflowSetupRun.ts apps/server/src/persistence/Migrations.ts apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts +git commit -m "feat(workflow): add lease, dispatch-outbox, and setup-run tables" +``` + +--- + +## Task 2: WorktreeLeaseService (fenced single-writer) + +**Files:** +- Create `workflow/Services/WorktreeLeaseService.ts` + `Layers/WorktreeLeaseService.ts` + test. + +- [ ] **Step 1: Write the failing test** + +```typescript +// apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; + +const layer = it.layer( + WorktreeLeaseServiceLive.pipe( + Layer.provideMerge(MigrationsLive), Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("WorktreeLeaseService", (it) => { + it.effect("acquire returns a monotonically increasing fence token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-1", "step", "sr-1"); + yield* lease.release("wt-1", a.fenceToken); + const b = yield* lease.acquire("wt-1", "step", "sr-2"); + assert.isAbove(b.fenceToken, a.fenceToken); + }), + ); + + it.effect("validate rejects a stale token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-2", "step", "sr-1"); + yield* lease.release("wt-2", a.fenceToken); + yield* lease.acquire("wt-2", "step", "sr-2"); + const valid = yield* lease.isValid("wt-2", a.fenceToken); + assert.equal(valid, false); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Write the interface** + +```typescript +// apps/server/src/workflow/Services/WorktreeLeaseService.ts +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface Lease { readonly fenceToken: number; } + +export interface WorktreeLeaseServiceShape { + readonly acquire: ( + worktreeRef: string, ownerKind: "step" | "user", ownerId: string, + ) => Effect.Effect; + readonly release: ( + worktreeRef: string, fenceToken: number, + ) => Effect.Effect; + readonly isValid: ( + worktreeRef: string, fenceToken: number, + ) => Effect.Effect; +} + +export class WorktreeLeaseService extends Context.Service< + WorktreeLeaseService, WorktreeLeaseServiceShape +>()("t3/workflow/Services/WorktreeLeaseService") {} +``` + +- [ ] **Step 4: Write the layer** + +```typescript +// apps/server/src/workflow/Layers/WorktreeLeaseService.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + WorktreeLeaseService, type Lease, type WorktreeLeaseServiceShape, +} from "../Services/WorktreeLeaseService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const LEASE_TTL_MS = 30 * 60 * 1000; +const wrap = (e: Effect.Effect) => + e.pipe(Effect.mapError((cause) => new WorkflowEventStoreError({ message: "lease op failed", cause }))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const acquire: WorktreeLeaseServiceShape["acquire"] = (worktreeRef, ownerKind, ownerId) => + Effect.gen(function* () { + const now = new Date(); + const expires = new Date(now.getTime() + LEASE_TTL_MS).toISOString(); + // Next fence = max existing + 1, persisted via upsert. A single SQL upsert keeps it atomic. + const rows = yield* wrap(sql<{ readonly fenceToken: number }>` + INSERT INTO worktree_lease + (worktree_ref, owner_kind, owner_id, fence_token, acquired_at, expires_at) + VALUES (${worktreeRef}, ${ownerKind}, ${ownerId}, + COALESCE((SELECT fence_token FROM worktree_lease WHERE worktree_ref = ${worktreeRef}), 0) + 1, + ${now.toISOString()}, ${expires}) + ON CONFLICT(worktree_ref) DO UPDATE SET + owner_kind = excluded.owner_kind, owner_id = excluded.owner_id, + fence_token = worktree_lease.fence_token + 1, + acquired_at = excluded.acquired_at, expires_at = excluded.expires_at + RETURNING fence_token AS "fenceToken"`); + return { fenceToken: rows[0]!.fenceToken } satisfies Lease; + }); + + const release: WorktreeLeaseServiceShape["release"] = (worktreeRef, fenceToken) => + wrap(sql` + DELETE FROM worktree_lease WHERE worktree_ref = ${worktreeRef} AND fence_token = ${fenceToken}`) + .pipe(Effect.asVoid); + + const isValid: WorktreeLeaseServiceShape["isValid"] = (worktreeRef, fenceToken) => + wrap(sql<{ readonly fenceToken: number }>` + SELECT fence_token AS "fenceToken" FROM worktree_lease WHERE worktree_ref = ${worktreeRef}`) + .pipe(Effect.map((rows) => rows[0]?.fenceToken === fenceToken)); + + return { acquire, release, isValid } satisfies WorktreeLeaseServiceShape; +}); + +export const WorktreeLeaseServiceLive = Layer.effect(WorktreeLeaseService, make); +``` + +> Note: acquiring overwrites any prior lease (it always bumps the fence). In v1 the engine never double-acquires a live lease (steps are sequential per ticket); the fence's job is to invalidate *superseded* writers, which `isValid` enforces. + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/WorktreeLeaseService.ts apps/server/src/workflow/Layers/WorktreeLeaseService.ts apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts +git commit -m "feat(workflow): add fenced WorktreeLeaseService" +``` + +--- + +## Task 3: SetupRunService (durable setup gate) + +Runs project setup scripts in the ticket worktree and resolves only when the setup terminal exits. + +**Files:** +- Create `workflow/Services/SetupRunService.ts` + `Layers/SetupRunService.ts` + test. + +- [ ] **Step 1: Write the failing test** (with stubbed runner + terminal) + +```typescript +// apps/server/src/workflow/Layers/SetupRunService.test.ts +// Provide stub implementations of the ProjectSetupScriptRunner port and a terminal +// exit source so the test is hermetic. Assert: runSetup(ticketId, worktree) inserts a +// 'running' row, and upon a simulated exitCode=0 the row becomes 'completed'; exitCode=1 +// becomes 'failed'. Use a Deferred you fire to emit the exit event. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { SetupRunServiceLive } from "./SetupRunService.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; + +const stubTerminal = (exitCode: number) => + Layer.succeed(SetupTerminalPort, { + // launch returns immediately; awaitExit resolves with the scripted code + launch: () => Effect.succeed({ terminalId: "term-1" }), + awaitExit: () => Effect.succeed({ exitCode }), + }); + +const mk = (exitCode: number) => + it.layer( + SetupRunServiceLive.pipe( + Layer.provideMerge(stubTerminal(exitCode)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory)), + ); + +mk(0)("SetupRunService (success)", (it) => { + it.effect("completes on exit 0", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const sql = yield* SqlClient.SqlClient; + const result = yield* setup.runSetup("t-1" as never, "wt-1", "/tmp/wt-1", "setup-1" as never); + assert.equal(result.status, "completed"); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_setup_run WHERE ticket_id = 't-1'`; + assert.equal(rows[0]?.status, "completed"); + }), + ); +}); + +mk(1)("SetupRunService (failure)", (it) => { + it.effect("fails on non-zero exit", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const result = yield* setup.runSetup("t-2" as never, "wt-2", "/tmp/wt-2", "setup-2" as never); + assert.equal(result.status, "failed"); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Write the interface (with a thin terminal port)** + +```typescript +// apps/server/src/workflow/Services/SetupRunService.ts +import type { SetupRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** Thin port over ProjectSetupScriptRunner + TerminalManager so the gate is testable. */ +export interface SetupTerminalPortShape { + readonly launch: (input: { + readonly worktreePath: string; + }) => Effect.Effect<{ readonly terminalId: string }, WorkflowEventStoreError>; + readonly awaitExit: (input: { + readonly terminalId: string; readonly timeoutMs?: number; + }) => Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError>; +} +export class SetupTerminalPort extends Context.Service< + SetupTerminalPort, SetupTerminalPortShape +>()("t3/workflow/Services/SetupTerminalPort") {} + +export type SetupStatus = "completed" | "failed" | "timed_out"; + +export interface SetupRunServiceShape { + readonly runSetup: ( + ticketId: TicketId, worktreeRef: string, worktreePath: string, setupRunId: SetupRunId, + ) => Effect.Effect<{ readonly status: SetupStatus; readonly exitCode: number | null }, + WorkflowEventStoreError>; +} +export class SetupRunService extends Context.Service< + SetupRunService, SetupRunServiceShape +>()("t3/workflow/Services/SetupRunService") {} +``` + +Add `SetupRunId` to `packages/contracts/src/workflow.ts` (`makeId("SetupRunId")`). + +- [ ] **Step 4: Write the layer** + +```typescript +// apps/server/src/workflow/Layers/SetupRunService.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + SetupRunService, SetupTerminalPort, type SetupRunServiceShape, +} from "../Services/SetupRunService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const SETUP_TIMEOUT_MS = 10 * 60 * 1000; +const wrap = (e: Effect.Effect) => + e.pipe(Effect.mapError((c) => new WorkflowEventStoreError({ message: "setup op failed", cause: c }))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const terminal = yield* SetupTerminalPort; + + const runSetup: SetupRunServiceShape["runSetup"] = (ticketId, worktreeRef, worktreePath, setupRunId) => + Effect.gen(function* () { + const now = new Date().toISOString(); + yield* wrap(sql` + INSERT INTO workflow_setup_run + (setup_run_id, ticket_id, worktree_ref, status, started_at) + VALUES (${setupRunId}, ${ticketId}, ${worktreeRef}, 'running', ${now}) + ON CONFLICT(ticket_id) DO UPDATE SET + setup_run_id = excluded.setup_run_id, status = 'running', + started_at = excluded.started_at, finished_at = NULL, exit_code = NULL`); + + const { terminalId } = yield* terminal.launch({ worktreePath }); + const exit = yield* terminal.awaitExit({ terminalId, timeoutMs: SETUP_TIMEOUT_MS }).pipe( + Effect.catchAll(() => Effect.succeed({ exitCode: -1 })), // timeout/crash → treat as failure below + ); + + const status = exit.exitCode === 0 ? "completed" : exit.exitCode === -1 ? "timed_out" : "failed"; + yield* wrap(sql` + UPDATE workflow_setup_run SET status = ${status}, exit_code = ${exit.exitCode}, + finished_at = ${new Date().toISOString()} WHERE ticket_id = ${ticketId}`); + return { status, exitCode: exit.exitCode }; + }); + + return { runSetup } satisfies SetupRunServiceShape; +}); + +export const SetupRunServiceLive = Layer.effect(SetupRunService, make); +``` + +The production `SetupTerminalPort` layer wires the real services: + +```typescript +// in the same file, the production port +import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; + +export const SetupTerminalPortLive = Layer.effect( + SetupTerminalPort, + Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner; + const terminals = yield* TerminalManager; + return { + launch: (input) => + // confirm the real runner signature; it returns { terminalId } + runner.runForThread(/* { worktreePath, ... } — confirm input shape */ input as never).pipe( + Effect.map((r) => ({ terminalId: (r as { terminalId: string }).terminalId })), + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "setup launch failed", cause })), + ), + awaitExit: (input) => + // subscribe to terminal events, resolve on TerminalExitedEvent { exitCode } + terminals.subscribe(/* terminalId */ input.terminalId as never).pipe( + // pseudo: take(first exited), map exitCode — implement against the real stream API + Effect.map(() => ({ exitCode: 0 })), + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "setup await failed", cause })), + ), + }; + }), +); +``` + +> Implementer: the production `SetupTerminalPortLive` is the one place you bridge to real services. Confirm `ProjectSetupScriptRunner.runForThread` input/return and the `TerminalManager` exit-event stream (`TerminalExitedEvent { exitCode }`), and implement `awaitExit` to take the first exited event with a timeout. The `SetupRunService` logic above is fully testable via the stub port. + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/SetupRunService.ts apps/server/src/workflow/Layers/SetupRunService.ts apps/server/src/workflow/Layers/SetupRunService.test.ts packages/contracts/src/workflow.ts +git commit -m "feat(workflow): add durable SetupRunService gated on terminal exit" +``` + +--- + +## Task 4: TurnStateReader (terminal-state from projections) + +Reads whether a thread's turn reached terminal state, from `projection_turns` — never a live stream. + +**Files:** +- Create `workflow/Services/TurnStateReader.ts` + `Layers/TurnStateReader.ts` + test. + +- [ ] **Step 1: Write the failing test** (stub the projection-turn port) + +```typescript +// apps/server/src/workflow/Layers/TurnStateReader.test.ts +// Provide a stub TurnProjectionPort returning a scripted state; assert TurnStateReader +// maps {completed -> {_tag:'completed'}}, {error -> failed}, {running/pending -> running}. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { TurnStateReader, TurnProjectionPort } from "../Services/TurnStateReader.ts"; +import { TurnStateReaderLive } from "./TurnStateReader.ts"; + +const stub = (state: string) => Layer.succeed(TurnProjectionPort, { + getLatestTurnState: () => Effect.succeed({ state, completed: state === "completed" || state === "error" }), +}); + +const mk = (state: string) => it.layer(TurnStateReaderLive.pipe(Layer.provideMerge(stub(state)))); + +mk("completed")("TurnStateReader completed", (it) => { + it.effect("maps completed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const r = yield* reader.read("thread-1" as never); + assert.equal(r._tag, "completed"); + }), + ); +}); +mk("error")("TurnStateReader error", (it) => { + it.effect("maps error to failed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + assert.equal((yield* reader.read("thread-1" as never))._tag, "failed"); + }), + ); +}); +mk("running")("TurnStateReader running", (it) => { + it.effect("maps running", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + assert.equal((yield* reader.read("thread-1" as never))._tag, "running"); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3 + 4: Interface + layer** + +```typescript +// apps/server/src/workflow/Services/TurnStateReader.ts +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export type TurnState = + | { readonly _tag: "running" } + | { readonly _tag: "completed" } + | { readonly _tag: "failed"; readonly error: string }; + +/** Port over the existing ProjectionTurnRepository so it's stubbable. */ +export interface TurnProjectionPortShape { + readonly getLatestTurnState: ( + threadId: ThreadId, + ) => Effect.Effect<{ readonly state: string; readonly completed: boolean }>; +} +export class TurnProjectionPort extends Context.Service< + TurnProjectionPort, TurnProjectionPortShape +>()("t3/workflow/Services/TurnProjectionPort") {} + +export interface TurnStateReaderShape { + readonly read: (threadId: ThreadId) => Effect.Effect; +} +export class TurnStateReader extends Context.Service< + TurnStateReader, TurnStateReaderShape +>()("t3/workflow/Services/TurnStateReader") {} +``` + +```typescript +// apps/server/src/workflow/Layers/TurnStateReader.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + TurnProjectionPort, TurnStateReader, type TurnState, type TurnStateReaderShape, +} from "../Services/TurnStateReader.ts"; + +const make = Effect.gen(function* () { + const port = yield* TurnProjectionPort; + const read: TurnStateReaderShape["read"] = (threadId) => + port.getLatestTurnState(threadId).pipe( + Effect.map(({ state }): TurnState => { + if (state === "completed") return { _tag: "completed" }; + if (state === "error" || state === "interrupted") + return { _tag: "failed", error: state }; + return { _tag: "running" }; + }), + ); + return { read } satisfies TurnStateReaderShape; +}); + +export const TurnStateReaderLive = Layer.effect(TurnStateReader, make); +``` + +The production `TurnProjectionPort` wraps `ProjectionTurnRepository.getByTurnId` / latest-turn-by-thread query: + +```typescript +// production port (same file or sibling) — confirm the exact repository method +export const TurnProjectionPortLive = Layer.effect( + TurnProjectionPort, + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; // from orchestration persistence + return { + getLatestTurnState: (threadId) => + turns.getLatestByThreadId(threadId).pipe( // confirm method name + Effect.map((t) => ({ state: t?.state ?? "pending", + completed: t?.state === "completed" || t?.state === "error" })), + Effect.orElseSucceed(() => ({ state: "pending", completed: false })), + ), + }; + }), +); +``` + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/TurnStateReader.ts apps/server/src/workflow/Layers/TurnStateReader.ts apps/server/src/workflow/Layers/TurnStateReader.test.ts +git commit -m "feat(workflow): add TurnStateReader (terminal state from projections)" +``` + +--- + +## Task 5: ProviderDispatchOutbox (durable, idempotent dispatch) + +Persists a dispatch intent, drives `ProviderService` to start the turn, and confirms from `TurnStateReader` (provider progress), retrying idempotently on restart. + +**Files:** +- Create `workflow/Services/ProviderDispatchOutbox.ts` + `Layers/ProviderDispatchOutbox.ts` + test. +- Define a `ProviderTurnPort` (stubbable wrapper over `ProviderService.startSession`+`sendTurn`). + +- [ ] **Step 1: Write the failing test** (stub provider + turn-state) + +```typescript +// apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts +// Stub ProviderTurnPort to record calls and a TurnStateReader that returns 'running' +// then 'completed'. Assert: ensureStarted writes a pending row, calls the provider once, +// and confirm() flips the row to 'confirmed' once turn state is terminal. Re-running +// ensureStarted with the same dispatchId does NOT call the provider again (idempotent). +``` + +(Write the concrete harness mirroring Task 4's stub-layer style; assert call counts via a `Ref` in the stub.) + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Interface** + +```typescript +// apps/server/src/workflow/Services/ProviderDispatchOutbox.ts +import type { DispatchId, StepRunId, ThreadId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DispatchRequest { + readonly dispatchId: DispatchId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: ThreadId; + readonly commandId: string; + readonly messageId: string; + readonly providerInstance: string; + readonly model: string; + readonly instruction: string; + readonly worktreePath: string; +} + +/** Stubbable wrapper over ProviderService start+turn. */ +export interface ProviderTurnPortShape { + readonly ensureTurnStarted: ( + req: DispatchRequest, + ) => Effect.Effect; +} +export class ProviderTurnPort extends Context.Service< + ProviderTurnPort, ProviderTurnPortShape +>()("t3/workflow/Services/ProviderTurnPort") {} + +export interface ProviderDispatchOutboxShape { + /** Persist intent + ensure provider turn is started (idempotent on dispatchId). */ + readonly ensureStarted: (req: DispatchRequest) => Effect.Effect; + /** Confirm dispatch once provider progress is terminal; returns the terminal turn state. */ + readonly awaitTerminal: ( + dispatchId: DispatchId, threadId: ThreadId, + ) => Effect.Effect<{ readonly ok: boolean; readonly error?: string }, WorkflowEventStoreError>; + /** Recovery: re-ensure all unconfirmed rows. */ + readonly recoverPending: () => Effect.Effect; +} +export class ProviderDispatchOutbox extends Context.Service< + ProviderDispatchOutbox, ProviderDispatchOutboxShape +>()("t3/workflow/Services/ProviderDispatchOutbox") {} +``` + +Add `DispatchId = makeId("DispatchId")` to `packages/contracts/src/workflow.ts`. + +- [ ] **Step 4: Layer** + +```typescript +// apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + ProviderDispatchOutbox, ProviderTurnPort, type DispatchRequest, + type ProviderDispatchOutboxShape, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const wrap = (e: Effect.Effect) => + e.pipe(Effect.mapError((c) => new WorkflowEventStoreError({ message: "dispatch op failed", cause: c }))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const provider = yield* ProviderTurnPort; + const turns = yield* TurnStateReader; + + const isConfirmed = (dispatchId: string) => + wrap(sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${dispatchId}`) + .pipe(Effect.map((r) => r[0]?.status === "confirmed")); + + const hasRow = (dispatchId: string) => + wrap(sql<{ readonly n: number }>` + SELECT COUNT(*) AS n FROM workflow_dispatch_outbox WHERE dispatch_id = ${dispatchId}`) + .pipe(Effect.map((r) => (r[0]?.n ?? 0) > 0)); + + const ensureStarted: ProviderDispatchOutboxShape["ensureStarted"] = (req) => + Effect.gen(function* () { + const exists = yield* hasRow(req.dispatchId); + if (!exists) { + yield* wrap(sql` + INSERT INTO workflow_dispatch_outbox + (dispatch_id, ticket_id, step_run_id, thread_id, command_id, message_id, + provider_instance, model, instruction, worktree_path, status, created_at) + VALUES (${req.dispatchId}, ${req.ticketId}, ${req.stepRunId}, ${req.threadId}, + ${req.commandId}, ${req.messageId}, ${req.providerInstance}, ${req.model}, + ${req.instruction}, ${req.worktreePath}, 'pending', ${new Date().toISOString()})`); + } + // Idempotency guard: only (re)start if provider progress is not yet observed. + const state = yield* turns.read(req.threadId); + if (state._tag === "running" && !(yield* isConfirmed(req.dispatchId))) { + // 'running' here means "no terminal yet" AND we haven't recorded a start — re-ensure. + yield* provider.ensureTurnStarted(req); + } + }); + + const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => + Effect.gen(function* () { + // Poll persisted provider progress until terminal (M5 can replace polling with a + // projection subscription; polling keeps M3 simple and restart-safe). + const state = yield* turns.read(threadId).pipe( + Effect.repeat({ + schedule: Schedule.spaced("500 millis"), + until: (s) => s._tag !== "running", + }), + ); + yield* wrap(sql` + UPDATE workflow_dispatch_outbox SET status = 'confirmed', + confirmed_at = ${new Date().toISOString()} WHERE dispatch_id = ${dispatchId}`); + return state._tag === "completed" + ? { ok: true } + : { ok: false, error: state._tag === "failed" ? state.error : "unknown" }; + }); + + const recoverPending: ProviderDispatchOutboxShape["recoverPending"] = () => + Effect.gen(function* () { + const rows = yield* wrap(sql` + SELECT dispatch_id AS "dispatchId", ticket_id AS "ticketId", step_run_id AS "stepRunId", + thread_id AS "threadId", command_id AS "commandId", message_id AS "messageId", + provider_instance AS "providerInstance", model, instruction, + worktree_path AS "worktreePath", status + FROM workflow_dispatch_outbox WHERE status = 'pending'`); + yield* Effect.forEach(rows, (r) => ensureStarted(r), { discard: true }); + }); + + return { ensureStarted, awaitTerminal, recoverPending } satisfies ProviderDispatchOutboxShape; +}); + +export const ProviderDispatchOutboxLive = Layer.effect(ProviderDispatchOutbox, make); +``` + +The production `ProviderTurnPort` wraps `ProviderService`: + +```typescript +// production port — confirm exact ProviderSessionStartInput / ProviderSendTurnInput fields +export const ProviderTurnPortLive = Layer.effect( + ProviderTurnPort, + Effect.gen(function* () { + const providerSvc = yield* ProviderService; + return { + ensureTurnStarted: (req) => + Effect.gen(function* () { + yield* providerSvc.startSession(req.threadId, { + // cwd: req.worktreePath, providerInstanceId: req.providerInstance, modelSelection ... + } as never); + yield* providerSvc.sendTurn({ + // threadId: req.threadId, commandId: req.commandId, message: { messageId, role:'user', text: req.instruction, attachments: [] }, modelSelection ... + } as never); + }).pipe( + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "provider start failed", cause })), + ), + }; + }), +); +``` + +> Implementer: `ProviderTurnPortLive` is the bridge to ACP. Fill `startSession`/`sendTurn` inputs from the verified `ProviderSessionStartInput`/`ProviderSendTurnInput` schemas (worktree cwd, provider instance, model selection, message text). Guard `startSession` so re-running on an existing session is a no-op or tolerated (provider may already have a session). All polling/idempotency logic above is unit-tested via the stub port. + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/ProviderDispatchOutbox.ts apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts packages/contracts/src/workflow.ts +git commit -m "feat(workflow): add durable provider-dispatch outbox" +``` + +--- + +## Task 6: RealStepExecutor (provides the M2 StepExecutor port) + +Composes lease + setup + dispatch + terminal-state into one agent-step execution. Approval steps remain handled by the engine; this executor handles only `agent` steps (per the M2 port contract). + +**Files:** +- Create `workflow/Layers/RealStepExecutor.ts` + test. +- Needs a worktree port (stub for tests): `WorktreePort` over `GitWorkflowService.createWorktree`. + +- [ ] **Step 1: Write the failing test** (stub lease/setup/dispatch/worktree ports; assert event/outcome) + +```typescript +// apps/server/src/workflow/Layers/RealStepExecutor.test.ts +// Compose RealStepExecutor with: stub WorktreePort (returns a fixed path/ref), +// real WorktreeLeaseService (sqlite memory), stub SetupRunService (completed), +// stub ProviderDispatchOutbox (ensureStarted no-op; awaitTerminal -> {ok:true}). +// Assert: executing an agent step returns { _tag: 'completed' } and that a lease was +// acquired and released (worktree_lease row absent after). Then a variant where +// awaitTerminal -> {ok:false,error} returns { _tag: 'failed' }. +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Write the worktree port + RealStepExecutor** + +```typescript +// apps/server/src/workflow/Services/WorktreePort.ts +import type { TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeHandle { + readonly worktreeRef: string; readonly path: string; +} +export interface WorktreePortShape { + /** Idempotent: returns the ticket's worktree, creating it on first call. */ + readonly ensureWorktree: ( + ticketId: TicketId, + ) => Effect.Effect; +} +export class WorktreePort extends Context.Service()( + "t3/workflow/Services/WorktreePort", +) {} +``` + +```typescript +// apps/server/src/workflow/Layers/RealStepExecutor.ts +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; + +const make = Effect.gen(function* () { + const worktrees = yield* WorktreePort; + const lease = yield* WorktreeLeaseService; + const setup = yield* SetupRunService; + const dispatch = yield* ProviderDispatchOutbox; + const ids = yield* WorkflowIds; + + const execute: StepExecutorShape["execute"] = (ctx) => + Effect.gen(function* () { + if (ctx.step.type !== "agent") return { _tag: "completed" } as StepOutcome; // safety + const wt = yield* worktrees.ensureWorktree(ctx.ticketId); + + // Setup gate (idempotent per ticket; a 'completed' row short-circuits in the service). + const setupRunId = yield* ids.eventId(); // reuse id generator for a setup run id + const setupResult = yield* setup.runSetup( + ctx.ticketId, wt.worktreeRef, wt.path, setupRunId as never); + if (setupResult.status !== "completed") { + return { _tag: "failed", error: `setup ${setupResult.status}` } as StepOutcome; + } + + // Acquire lease for the agent turn. + const acquired = yield* lease.acquire(wt.worktreeRef, "step", ctx.stepRunId as string); + + const dispatchId = yield* ids.eventId(); + const threadId = yield* ids.eventId(); // one thread per step + const commandId = yield* ids.eventId(); + const messageId = yield* ids.eventId(); + const instruction = typeof ctx.step.instruction === "string" + ? ctx.step.instruction + : `@@file:${ctx.step.instruction.file}`; // M5/loader resolves file → text; here pass marker + + yield* dispatch.ensureStarted({ + dispatchId: dispatchId as never, ticketId: ctx.ticketId, stepRunId: ctx.stepRunId, + threadId: threadId as never, commandId: commandId as string, messageId: messageId as string, + providerInstance: ctx.step.agent.instance as string, model: ctx.step.agent.model as string, + instruction, worktreePath: wt.path, + }); + + const result = yield* dispatch.awaitTerminal(dispatchId as never, threadId as never); + + // Release the lease iff still ours (fencing). + yield* Effect.whenEffect( + lease.isValid(wt.worktreeRef, acquired.fenceToken), + lease.release(wt.worktreeRef, acquired.fenceToken), + ); + + return (result.ok + ? { _tag: "completed" } + : { _tag: "failed", error: result.error ?? "turn failed" }) as StepOutcome; + }).pipe(Effect.orElseSucceed(() => ({ _tag: "failed", error: "executor error" }) as StepOutcome)); + + return { execute } satisfies StepExecutorShape; +}); + +export const RealStepExecutorLive = Layer.effect(StepExecutor, make); +``` + +> Notes: (1) The instruction-file → text resolution is deferred to the loader (M5) / a small helper; for M3 the marker is acceptable since tests use inline instructions. (2) `Effect.whenEffect` predicate/usage — confirm the exact combinator name in the beta (`Effect.whenEffect` or `Effect.when`); the intent is "release only if our fence is still current." (3) Provider questions/approvals surfacing as `awaiting_user`: in M3 the simplest correct behavior is — if `awaitTerminal` observes the turn parked on a pending question (extend `TurnStateReader` to detect a pending-approval/user-input projection and return a `{_tag:'awaiting_user'}` turn state), return `{ _tag: "awaiting_user", waitingReason }`. The M2 engine already parks on that outcome and resumes via `resolveApproval`, which for a volatile provider question maps to `ProviderService.respondToUserInput`/`respondToRequest`. Wire that response path in Task 7. + +- [ ] **Step 4: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/WorktreePort.ts apps/server/src/workflow/Layers/RealStepExecutor.ts apps/server/src/workflow/Layers/RealStepExecutor.test.ts +git commit -m "feat(workflow): add RealStepExecutor (worktree+lease+setup+dispatch)" +``` + +--- + +## Task 7: Durable approvals + volatile-question response wiring + +Make `approval` steps durable (engine-owned, already persisted as workflow events in M2 — confirm they survive restart) and wire provider-question responses to `ProviderService`. + +**Files:** +- Create `workflow/Layers/DurableApprovalResume.ts` (recovery of parked approvals from `workflow_events`) + test. +- Extend the engine's `resolveApproval` to route provider-question answers to a `ProviderResponsePort`. + +- [ ] **Step 1: Write the failing test** + +Test that, given a `workflow_events` log where a `StepAwaitingUser` has no later `StepUserResolved`, recovery re-registers the parked step so a subsequent `resolveApproval` resumes it. For a provider-volatile question, assert `resolveApproval` invokes the `ProviderResponsePort`. + +- [ ] **Step 2–4: Implement** + +- Add a `ProviderResponsePort` (stub for tests) wrapping `ProviderService.respondToUserInput`/`respondToRequest`. +- On startup, scan `workflow_events` for steps in `awaiting_user` with no resolution; for **approval** steps, re-arm the `ApprovalGate`; for **agent** (provider-question) steps whose provider session is gone, mark them for **idempotent re-dispatch** (set the dispatch-outbox row back to `pending` so the dispatch worker re-runs the turn — per spec §7.3). +- `resolveApproval(stepRunId, approved)`: + - approval step → resolve the gate (existing M2 behavior, now durable via recovery). + - agent provider-question → call `ProviderResponsePort.respond(threadId, answer)`; the running dispatch `awaitTerminal` then proceeds to terminal. + +```typescript +// sketch — apps/server/src/workflow/Services/ProviderResponsePort.ts +export interface ProviderResponsePortShape { + readonly respond: (input: { + readonly threadId: ThreadId; readonly approved: boolean; readonly text?: string; + }) => Effect.Effect; +} +``` + +> Implementer: the production `ProviderResponsePort` calls `ProviderService.respondToUserInput` (for questions) or `respondToRequest` (for tool approvals) — confirm which the agent step is parked on via the pending-approval projection, and pick the matching method. + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/ProviderResponsePort.ts apps/server/src/workflow/Layers/DurableApprovalResume.ts apps/server/src/workflow/Layers/DurableApprovalResume.test.ts apps/server/src/workflow/Layers/WorkflowEngine.ts +git commit -m "feat(workflow): durable approvals + provider-question response wiring" +``` + +--- + +## Task 8: WorkflowRecovery (startup reconciliation) + +On boot, reconcile in-flight state per spec §7.9. + +**Files:** +- Create `workflow/Services/WorkflowRecovery.ts` + `Layers/WorkflowRecovery.ts` + test. + +- [ ] **Steps:** Implement `recover()` that runs, in order: + 1. `ProviderDispatchOutbox.recoverPending()` — re-ensure unconfirmed dispatches. + 2. For each ticket with a `running` pipeline but a terminal persisted turn state → finalize the step (emit `StepCompleted`/`StepFailed` + continue/route). + 3. `DurableApprovalResume` — re-arm parked approvals; mark lost-session agent questions for re-dispatch. + 4. Release leases whose owning StepRun is terminal/superseded. + + Test with a seeded sqlite state (pending outbox row + a `running` pipeline whose stubbed turn state is `completed`) and assert recovery confirms the dispatch and the step completes. Wire `recover()` to run once at server start (in the runtime startup layer — see Task 9), behind the existing reactor-startup pattern. + +- [ ] **Commit:** + +```bash +git add apps/server/src/workflow/Services/WorkflowRecovery.ts apps/server/src/workflow/Layers/WorkflowRecovery.ts apps/server/src/workflow/Layers/WorkflowRecovery.test.ts +git commit -m "feat(workflow): add startup recovery reconciliation" +``` + +--- + +## Task 9: Mock ACP provider + end-to-end integration with restart; aggregate runtime layer + +**Files:** +- Create `workflow/Layers/MockAcpProvider.ts` — a `ProviderTurnPort` + `TurnProjectionPort` test double driven by a script (advance a thread from running → completed on demand, or park on a question). +- Create `workflow/WorkflowRuntimeLive.ts` — aggregates: M2 engine (with `RealStepExecutorLive` instead of the stub) + lease + setup + dispatch + turn-state + recovery, plus the production ports (`ProviderTurnPortLive`, `TurnProjectionPortLive`, `SetupTerminalPortLive`, real `WorktreePort`). +- Create `workflow/Layers/WorkflowRuntime.integration.test.ts`. + +- [ ] **Step 1: Integration test (mock provider)** + +Drive a 3-lane workflow (code → review → done) where the mock provider completes each agent turn; assert the ticket reaches `done` with both steps `completed`. Then a **restart** variant: kill the runtime mid-turn (leave a pending outbox row + non-terminal turn), rebuild the layer, run `recover()`, advance the mock turn to `completed`, and assert no duplicate threads were started (assert the mock's start-call count == 1 per step) and the ticket completes. + +- [ ] **Step 2: Aggregate layer** + +```typescript +// apps/server/src/workflow/WorkflowRuntimeLive.ts +import * as Layer from "effect/Layer"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { RealStepExecutorLive } from "./Layers/RealStepExecutor.ts"; +import { WorktreeLeaseServiceLive } from "./Layers/WorktreeLeaseService.ts"; +import { SetupRunServiceLive } from "./Layers/SetupRunService.ts"; +import { ProviderDispatchOutboxLive } from "./Layers/ProviderDispatchOutbox.ts"; +import { TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; +import { WorkflowRecoveryLive } from "./Layers/WorkflowRecovery.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; +// + production ports: ProviderTurnPortLive, TurnProjectionPortLive, SetupTerminalPortLive, WorktreePortLive + +export const WorkflowRuntimeLive = WorkflowEngineLayer.pipe( + Layer.provide(RealStepExecutorLive), + Layer.provide(ProviderDispatchOutboxLive), + Layer.provide(TurnStateReaderLive), + Layer.provide(SetupRunServiceLive), + Layer.provide(WorktreeLeaseServiceLive), + Layer.provide(WorkflowRecoveryLive), + Layer.provide(WorkflowEventCommitterLive), + Layer.provide(BoardRegistryLive), + Layer.provide(ApprovalGateLive), + Layer.provide(WorkflowIdsLive), + Layer.provide(WorkflowFoundationLive), + // + the production ports listed above +); +``` + +- [ ] **Step 3: Run → PASS. Typecheck. Commit.** + +```bash +git add apps/server/src/workflow/Layers/MockAcpProvider.ts apps/server/src/workflow/WorkflowRuntimeLive.ts apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts +git commit -m "feat(workflow): mock ACP provider + e2e runtime with restart recovery" +``` + +--- + +## Milestone Done — Definition of Done + +- [ ] `pnpm --filter @t3tools/server test -- workflow` green; `typecheck` passes. +- [ ] With the mock provider, a ticket runs a real agent step in a worktree (lease held during the turn, released after), gated behind a completed setup run, dispatched via the durable outbox, with completion detected from persisted provider progress. +- [ ] A simulated restart mid-turn recovers without starting a duplicate thread and drives the ticket to completion. +- [ ] Approval steps survive restart; a provider question parks the ticket `waiting_on_user` and resumes via `resolveApproval`. +- [ ] **Next:** Milestone 4 (ticket checkpoints + accumulated diff), then Milestone 5 (RPC + UI). + +--- + +## Notes for the implementer + +- **Ports are the test seams.** Every external dependency (provider, terminal/setup, worktree, turn projection) is behind a small port with a stub layer for unit tests and a `*Live` layer that bridges to the real t3code service. Keep the orchestration logic in the service tested against stubs; keep the bridging in the `*Live` ports, where you confirm exact upstream signatures. +- **Confirm these real signatures while wiring `*Live` ports:** `ProviderService.startSession/sendTurn/respondToUserInput/respondToRequest` inputs; `ProjectionTurnRepository` latest-turn-by-thread query + `state` values; `ProjectSetupScriptRunner.runForThread` + `TerminalManager` exit-event stream (`TerminalExitedEvent`); `GitWorkflowService.createWorktree` return (`{ worktree: { path, refName } }`). +- **Polling vs subscription:** `awaitTerminal` polls `projection_turns` (restart-safe, simple). If you'd rather subscribe to the projection stream, do it in `*Live` without changing the outbox logic — but keep confirmation based on persisted state. +- **Idempotency is the whole point of §7.8:** never confirm on command intake; only on terminal provider progress. The restart integration test (Task 9) is the guard — keep it. From b53a906a3628a04435cf2b4e4075fb8b72d20cd1 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 00:25:32 -0400 Subject: [PATCH 006/295] docs: add workflow boards v1 Milestones 4 & 5 plans (diff + RPC/UI) M4: ticket-level checkpoints (baseline + per-step refs in a ticket ref namespace) and a TicketDiffQuery for the accumulated base->worktree diff, reusing CheckpointStore capture and the git diff plumbing. M5: workflow.* WebSocket RPC (methods, schemas, scopes, handlers, board subscription), a WorkflowFileLoader that lints+registers .t3/boards files, runtime wiring + startup recovery, and the web board UI (dnd-kit lanes, ticket cards, drill-in with timeline/question-inbox/diff). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-07-workflow-boards-v1-m4-ticket-diff.md | 461 +++++++++++++++ ...2026-06-07-workflow-boards-v1-m5-rpc-ui.md | 559 ++++++++++++++++++ 2 files changed, 1020 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md create mode 100644 docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md new file mode 100644 index 00000000000..72ee67d5d18 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md @@ -0,0 +1,461 @@ +# Workflow Boards v1 — Milestone 4: Ticket Checkpoints & Accumulated Diff — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give each ticket a baseline ref and per-step pre/post checkpoints, and a `TicketDiffQuery` that produces the **accumulated** diff (baseline → current worktree) for the drill-in — distinct from the existing per-turn ref-to-ref diff. + +**Architecture:** Reuse the existing checkpoint plumbing (`git write-tree` → `commit-tree` → `update-ref`, and `git diff --patch`) under a **ticket ref namespace** `refs/t3/tickets//…`. A `TicketCheckpointService` captures refs; a `TicketDiffQuery` computes base-to-working-tree patches and parses per-file summaries via the existing `parseTurnDiffFilesFromUnifiedDiff`. Capture is wired into ticket creation (baseline) and `RealStepExecutor` (per-step pre/post). + +**Tech Stack:** TypeScript, Effect 4, git plumbing via the VCS layer, `@effect/vitest` with a real temp git repo. + +**Spec:** `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md` (§7.10). + +**Reference signatures (verified):** +- `CheckpointStore.captureCheckpoint({ cwd, checkpointRef })`, `diffCheckpoints({ cwd, fromCheckpointRef, toCheckpointRef, ignoreWhitespace })`, `hasCheckpointRef`, `deleteCheckpointRefs` — `apps/server/src/checkpointing/Services/CheckpointStore.ts`. +- Ref format util `checkpointRefForThreadTurn` and `CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"` — `apps/server/src/checkpointing/Utils.ts`. +- Working-tree diff: `git diff --patch --minimal HEAD --` and ref-range `git diff --patch ...HEAD` — `GitVcsDriverCore.ts` (`getReviewDiffPreview`). +- Per-file summary parser: `parseTurnDiffFilesFromUnifiedDiff(diff) → [{ path, additions, deletions }]` — `apps/server/src/checkpointing/Diffs.ts`. +- Diff result contract shape: `ReviewDiffPreviewSource` — `packages/contracts/src/review.ts`. +- Test repo helper `initRepoWithCommit(cwd)` — `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts`. + +**Depends on:** M1–M3. **Out of scope:** RPC/UI exposure of the diff (M5 surfaces it). + +--- + +## File Structure + +**Create:** +- `apps/server/src/persistence/Migrations/0DD_WorkflowStepRefs.ts` — add `pre_checkpoint_ref`, `post_checkpoint_ref` to `projection_step_run`. +- `apps/server/src/workflow/ticketRefs.ts` — pure ref-name helpers. +- `apps/server/src/workflow/Services/TicketCheckpointService.ts` + `Layers/TicketCheckpointService.ts` + test. +- `apps/server/src/workflow/Services/TicketDiffQuery.ts` + `Layers/TicketDiffQuery.ts` + test. + +**Modify:** +- `apps/server/src/persistence/Migrations.ts` — register the migration. +- `packages/contracts/src/workflow.ts` — add `TicketDiff` result schema. +- `apps/server/src/workflow/Layers/WorkflowEngine.ts` — capture baseline on ticket creation. +- `apps/server/src/workflow/Layers/RealStepExecutor.ts` — capture pre/post around the agent turn; persist refs. + +--- + +## Task 1: Ticket ref helpers (pure) + +**Files:** Create `apps/server/src/workflow/ticketRefs.ts` + `ticketRefs.test.ts`. + +- [ ] **Step 1: Failing test** + +```typescript +// apps/server/src/workflow/ticketRefs.test.ts +import { assert, describe, it } from "@effect/vitest"; +import { ticketBaseRef, ticketStepRef } from "./ticketRefs.ts"; + +describe("ticketRefs", () => { + it("builds a stable base ref", () => { + assert.equal(ticketBaseRef("t-1" as never), "refs/t3/tickets/dC0x/base"); + }); + it("builds pre/post step refs", () => { + assert.equal(ticketStepRef("t-1" as never, "sr-1" as never, "pre"), + "refs/t3/tickets/dC0x/step/c3ItMQ/pre"); + }); +}); +``` + +> The base64url encodings above assume `encodeBase64Url("t-1") = "dC0x"` and `encodeBase64Url("sr-1") = "c3ItMQ"`. Compute the real expected values from the actual encoder when writing the test (run the encoder once), or assert structurally (`startsWith("refs/t3/tickets/")` and contains `/base`). + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** + +```typescript +// apps/server/src/workflow/ticketRefs.ts +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; // confirm the import path used by checkpointing/Utils.ts + +export const TICKET_REFS_PREFIX = "refs/t3/tickets"; + +const enc = (s: string) => Encoding.encodeBase64Url(s); + +export const ticketBaseRef = (ticketId: TicketId): string => + `${TICKET_REFS_PREFIX}/${enc(ticketId as string)}/base`; + +export const ticketStepRef = ( + ticketId: TicketId, stepRunId: StepRunId, kind: "pre" | "post", +): string => + `${TICKET_REFS_PREFIX}/${enc(ticketId as string)}/step/${enc(stepRunId as string)}/${kind}`; +``` + +> Match `apps/server/src/checkpointing/Utils.ts` for the exact base64url helper it uses (`Encoding.encodeBase64Url`); mirror that import so encodings are consistent. + +- [ ] **Step 4: Run → PASS. Step 5: Commit.** + +```bash +git add apps/server/src/workflow/ticketRefs.ts apps/server/src/workflow/ticketRefs.test.ts +git commit -m "feat(workflow): add ticket ref-name helpers" +``` + +--- + +## Task 2: Migration — per-step checkpoint refs + +**Files:** Create `0DD_WorkflowStepRefs.ts`; modify `Migrations.ts`; test. + +- [ ] **Step 1: Failing test** + +```typescript +// apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); +layer("step refs migration", (it) => { + it.effect("projection_step_run has pre/post ref columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; + const names = cols.map((c) => c.name); + assert.isTrue(names.includes("pre_checkpoint_ref")); + assert.isTrue(names.includes("post_checkpoint_ref")); + }), + ); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** + +```typescript +// apps/server/src/persistence/Migrations/0DD_WorkflowStepRefs.ts +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN pre_checkpoint_ref TEXT`; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN post_checkpoint_ref TEXT`; +}); +``` + +Register in `Migrations.ts` (next integer). Extend `WorkflowProjectionPipeline` to set these columns on a new `StepRefsCaptured` event (add to the M1 event union) OR write them directly from the executor via a small repo method. For simplicity, add a `StepRefsCaptured` event: + +```typescript +// append to packages/contracts/src/workflow.ts WorkflowEvent union +Schema.Struct({ ...EventBase, type: Schema.Literal("StepRefsCaptured"), + payload: Schema.Struct({ stepRunId: StepRunId, + preRef: Schema.String, postRef: Schema.String }) }), +``` + +and in `WorkflowProjectionPipeline`: + +```typescript +case "StepRefsCaptured": { + yield* sql` + UPDATE projection_step_run SET pre_checkpoint_ref = ${event.payload.preRef}, + post_checkpoint_ref = ${event.payload.postRef} WHERE step_run_id = ${event.payload.stepRunId}`; + break; +} +``` + +- [ ] **Step 4: Run → PASS. Step 5: Commit.** + +```bash +git add apps/server/src/persistence/Migrations/0DD_WorkflowStepRefs.ts apps/server/src/persistence/Migrations.ts packages/contracts/src/workflow.ts apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts +git commit -m "feat(workflow): add per-step checkpoint ref columns + StepRefsCaptured event" +``` + +--- + +## Task 3: TicketCheckpointService + +Captures the baseline ref and per-step pre/post refs using the existing `CheckpointStore`. + +**Files:** Create service + layer + test (real temp git repo). + +- [ ] **Step 1: Failing test** + +```typescript +// apps/server/src/workflow/Layers/TicketCheckpointService.test.ts +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +// Reuse the checkpointing test helpers for a temp repo + the real CheckpointStore layer. +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +// import { makeTmpDir, initRepoWithCommit } from checkpointing test utils (or inline minimal git setup) + +const layer = it.layer( + TicketCheckpointServiceLive.pipe(Layer.provideMerge(CheckpointStoreLive /* + its deps */)), +); + +layer("TicketCheckpointService", (it) => { + it.effect("captures a baseline ref that exists", () => + Effect.gen(function* () { + // const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); + const tmp = "/tmp/should-be-a-real-initialized-repo"; + const svc = yield* TicketCheckpointService; + yield* svc.captureBaseline("t-1" as never, tmp); + const exists = yield* svc.hasBaseline("t-1" as never, tmp); + assert.equal(exists, true); + }), + ); +}); +``` + +> Use the real `initRepoWithCommit`/`makeTmpDir` helpers from `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts` (import or copy minimal versions). Checkpoint capture needs a real git repo. + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Interface** + +```typescript +// apps/server/src/workflow/Services/TicketCheckpointService.ts +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface TicketCheckpointServiceShape { + readonly captureBaseline: ( + ticketId: TicketId, cwd: string, + ) => Effect.Effect; // returns the baseline ref + readonly hasBaseline: ( + ticketId: TicketId, cwd: string, + ) => Effect.Effect; + readonly captureStep: ( + ticketId: TicketId, stepRunId: StepRunId, cwd: string, kind: "pre" | "post", + ) => Effect.Effect; // returns the step ref +} +export class TicketCheckpointService extends Context.Service< + TicketCheckpointService, TicketCheckpointServiceShape +>()("t3/workflow/Services/TicketCheckpointService") {} +``` + +- [ ] **Step 4: Layer** + +```typescript +// apps/server/src/workflow/Layers/TicketCheckpointService.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { CheckpointRef } from "@t3tools/contracts"; // confirm where CheckpointRef is exported +import { + TicketCheckpointService, type TicketCheckpointServiceShape, +} from "../Services/TicketCheckpointService.ts"; +import { ticketBaseRef, ticketStepRef } from "../ticketRefs.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const wrap = (e: Effect.Effect) => + e.pipe(Effect.mapError((c) => new WorkflowEventStoreError({ message: "checkpoint op failed", cause: c }))); + +const make = Effect.gen(function* () { + const checkpoints = yield* CheckpointStore; + + const captureBaseline: TicketCheckpointServiceShape["captureBaseline"] = (ticketId, cwd) => + Effect.gen(function* () { + const ref = ticketBaseRef(ticketId); + yield* wrap(checkpoints.captureCheckpoint({ cwd, checkpointRef: CheckpointRef.make(ref) })); + return ref; + }); + + const hasBaseline: TicketCheckpointServiceShape["hasBaseline"] = (ticketId, cwd) => + wrap(checkpoints.hasCheckpointRef({ cwd, checkpointRef: CheckpointRef.make(ticketBaseRef(ticketId)) })); + + const captureStep: TicketCheckpointServiceShape["captureStep"] = (ticketId, stepRunId, cwd, kind) => + Effect.gen(function* () { + const ref = ticketStepRef(ticketId, stepRunId, kind); + yield* wrap(checkpoints.captureCheckpoint({ cwd, checkpointRef: CheckpointRef.make(ref) })); + return ref; + }); + + return { captureBaseline, hasBaseline, captureStep } satisfies TicketCheckpointServiceShape; +}); + +export const TicketCheckpointServiceLive = Layer.effect(TicketCheckpointService, make); +``` + +> Confirm `CheckpointStore.hasCheckpointRef` input shape (`{ cwd, checkpointRef }`) and where `CheckpointRef` is exported (likely contracts or checkpointing). `captureCheckpoint` snapshots the current worktree into the ref — exactly what baseline (at creation) and pre/post (around a step) need. + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/TicketCheckpointService.ts apps/server/src/workflow/Layers/TicketCheckpointService.ts apps/server/src/workflow/Layers/TicketCheckpointService.test.ts +git commit -m "feat(workflow): add TicketCheckpointService (baseline + per-step refs)" +``` + +--- + +## Task 4: TicketDiffQuery (accumulated base→worktree diff) + +**Files:** Create service + layer + test; add `TicketDiff` contract. + +- [ ] **Step 1: Add the contract** + +```typescript +// append to packages/contracts/src/workflow.ts +export const TicketDiffFile = Schema.Struct({ + path: Schema.String, additions: Schema.Int, deletions: Schema.Int, +}); +export const TicketDiff = Schema.Struct({ + ticketId: TicketId, + baseRef: Schema.String, + patch: Schema.String, // raw unified diff (may be truncated) + files: Schema.Array(TicketDiffFile), + truncated: Schema.Boolean, +}); +export type TicketDiff = typeof TicketDiff.Type; +``` + +- [ ] **Step 2: Failing test** + +```typescript +// apps/server/src/workflow/Layers/TicketDiffQuery.test.ts +// With a real temp repo: capture baseline, write a file, then assert getTicketDiff returns +// files=[{path, additions>0, deletions}] and patch contains "diff --git". +``` + +- [ ] **Step 3: Interface** + +```typescript +// apps/server/src/workflow/Services/TicketDiffQuery.ts +import type { TicketDiff, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** Stubbable port over the git working-tree diff plumbing. */ +export interface WorktreeDiffPortShape { + readonly diffRefToWorktree: (input: { + readonly cwd: string; readonly baseRef: string; + }) => Effect.Effect<{ readonly patch: string; readonly truncated: boolean }, WorkflowEventStoreError>; +} +export class WorktreeDiffPort extends Context.Service< + WorktreeDiffPort, WorktreeDiffPortShape +>()("t3/workflow/Services/WorktreeDiffPort") {} + +export interface TicketDiffQueryShape { + readonly getTicketDiff: ( + ticketId: TicketId, cwd: string, baseRef: string, + ) => Effect.Effect; +} +export class TicketDiffQuery extends Context.Service< + TicketDiffQuery, TicketDiffQueryShape +>()("t3/workflow/Services/TicketDiffQuery") {} +``` + +- [ ] **Step 4: Layer** + +```typescript +// apps/server/src/workflow/Layers/TicketDiffQuery.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { + TicketDiffQuery, WorktreeDiffPort, type TicketDiffQueryShape, +} from "../Services/TicketDiffQuery.ts"; + +const make = Effect.gen(function* () { + const port = yield* WorktreeDiffPort; + const getTicketDiff: TicketDiffQueryShape["getTicketDiff"] = (ticketId, cwd, baseRef) => + Effect.gen(function* () { + const { patch, truncated } = yield* port.diffRefToWorktree({ cwd, baseRef }); + const files = parseTurnDiffFilesFromUnifiedDiff(patch); + return { ticketId, baseRef, patch, files, truncated }; + }); + return { getTicketDiff } satisfies TicketDiffQueryShape; +}); + +export const TicketDiffQueryLive = Layer.effect(TicketDiffQuery, make); +``` + +Production `WorktreeDiffPort` shells out to git (mirror `GitVcsDriverCore.getReviewDiffPreview`): + +```typescript +// production port — diff the baseline ref against the live working tree +export const WorktreeDiffPortLive = Layer.effect( + WorktreeDiffPort, + Effect.gen(function* () { + const git = yield* GitVcsDriverCore; // or the executeGit helper / GitManager + return { + diffRefToWorktree: ({ cwd, baseRef }) => + // git diff --patch --minimal -- (ref vs working tree, incl. uncommitted) + git.execute(/* operation */ "workflow.ticketDiff", cwd, + ["diff", "--patch", "--minimal", `${baseRef}^{commit}`, "--"], + { maxOutputBytes: 120_000, appendTruncationMarker: true } as never).pipe( + Effect.map((r: { stdout: string; stdoutTruncated?: boolean }) => + ({ patch: r.stdout, truncated: r.stdoutTruncated ?? false })), + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "ticket diff failed", cause })), + ), + }; + }), +); +``` + +> Implementer: confirm the exact git-exec helper (`executeGit`/`GitVcsDriverCore.execute`) and its options (`maxOutputBytes`, truncation flag) from `GitVcsDriverCore.ts`. The command `git diff --patch ^{commit} --` diffs the baseline commit against the working tree (uncommitted changes included), which is the accumulated ticket diff per §7.10. Untracked files: mirror how `getReviewDiffPreview` includes untracked (it appends a second diff for untracked paths) if you want them shown. + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Services/TicketDiffQuery.ts apps/server/src/workflow/Layers/TicketDiffQuery.ts apps/server/src/workflow/Layers/TicketDiffQuery.test.ts packages/contracts/src/workflow.ts +git commit -m "feat(workflow): add TicketDiffQuery (accumulated base->worktree diff)" +``` + +--- + +## Task 5: Wire capture into ticket creation and step execution + +**Files:** Modify `WorkflowEngine.ts` (baseline on create) and `RealStepExecutor.ts` (pre/post around the turn). + +- [ ] **Step 1: Baseline at ticket creation** + +In `WorkflowEngine` `createTicket`, after the worktree exists (note: worktree is created lazily on first step in M3; baseline must be captured at worktree creation time, not ticket creation). Move baseline capture into `WorktreePort.ensureWorktree` consumers: in `RealStepExecutor`, immediately after `ensureWorktree`, if no baseline exists yet, capture it: + +```typescript +// in RealStepExecutor.execute, after ensureWorktree: +const hasBase = yield* ticketCheckpoints.hasBaseline(ctx.ticketId, wt.path); +if (!hasBase) yield* ticketCheckpoints.captureBaseline(ctx.ticketId, wt.path); +``` + +- [ ] **Step 2: Pre/post around the agent turn + persist refs** + +```typescript +// in RealStepExecutor.execute, around the dispatch/await: +const preRef = yield* ticketCheckpoints.captureStep(ctx.ticketId, ctx.stepRunId, wt.path, "pre"); +// ... ensureStarted + awaitTerminal ... +const postRef = yield* ticketCheckpoints.captureStep(ctx.ticketId, ctx.stepRunId, wt.path, "post"); +// emit StepRefsCaptured via the committer so projection_step_run records them: +yield* committer.commit({ type: "StepRefsCaptured", eventId: yield* ids.eventId(), + ticketId: ctx.ticketId, occurredAt: new Date().toISOString() as never, + payload: { stepRunId: ctx.stepRunId, preRef, postRef } } as never); +``` + +Add `TicketCheckpointService` and `WorkflowEventCommitter` to `RealStepExecutor`'s dependencies. + +- [ ] **Step 3: Update the RealStepExecutor test** to assert `StepRefsCaptured` is emitted and `projection_step_run` has the refs after a step (use a stub `TicketCheckpointService` returning fixed ref strings to keep the executor test hermetic; the real git capture is covered by Task 3/4 tests). + +- [ ] **Step 4: Run → PASS. Commit.** + +```bash +git add apps/server/src/workflow/Layers/RealStepExecutor.ts apps/server/src/workflow/Layers/RealStepExecutor.test.ts apps/server/src/workflow/Layers/WorkflowEngine.ts +git commit -m "feat(workflow): capture ticket baseline + per-step refs during execution" +``` + +--- + +## Milestone Done — Definition of Done + +- [ ] `pnpm --filter @t3tools/server test -- workflow` green; `typecheck` passes. +- [ ] A ticket has a baseline ref at first-step time; each agent step records pre/post refs on `projection_step_run`. +- [ ] `TicketDiffQuery.getTicketDiff` returns the accumulated baseline→worktree patch + per-file summaries for a real repo. +- [ ] **Next:** Milestone 5 exposes all of this over `workflow.*` RPC and renders the board + drill-in (including the ticket diff). + +--- + +## Notes for the implementer + +- **Reuse, don't reinvent:** capture uses `CheckpointStore.captureCheckpoint` verbatim (just a different ref namespace). Only the **base→worktree** diff is new, because `CheckpointStore.diffCheckpoints` is ref-to-ref; the accumulated diff must include uncommitted working-tree state. +- **Real-git tests:** checkpoint/diff tests need a temp repo. Reuse `initRepoWithCommit`/`makeTmpDir` from `checkpointing/Layers/CheckpointStore.test.ts`. Keep the executor's own test hermetic with a stub `TicketCheckpointService`. +- **Truncation:** the accumulated diff can be large; carry the `truncated` flag through to the UI (M5) like `getReviewDiffPreview` does. diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md new file mode 100644 index 00000000000..dde6f2d4669 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md @@ -0,0 +1,559 @@ +# Workflow Boards v1 — Milestone 5: RPC + UI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose the workflow engine over `workflow.*` WebSocket RPC, load `.t3/boards/*.json` into live boards (with real provider/file lint), and render the board (lanes + drag-able cards) and the ticket drill-in (step timeline, live agent activity, question/approval inbox answered inline, accumulated diff). + +**Architecture:** Server adds `WORKFLOW_WS_METHODS` constants + `Rpc.make` schemas in contracts, handlers in `ws.ts` via `WsRpcGroup.of` (`observeRpcEffect` / `observeRpcStreamEffect`), and auth scopes; a `WorkflowFileLoader` reads/lints/registers board files; `WorkflowRuntimeLive` (M3) is wired into the server runtime with recovery at startup. Web adds a board route, a board-state zustand slice fed by a `workflow.subscribeBoard` stream, the board view (dnd-kit, already used in `Sidebar.tsx`), and the ticket drill-in reusing existing thread-activity/diff rendering. + +**Tech Stack:** Server: Effect 4, `Rpc.make`, `WsRpcGroup`. Web: React 19, TanStack Router, Zustand, dnd-kit, Tailwind + CVA, `vite-plus/test`. + +**Spec:** §6 (file), §8 (UI). **Depends on:** M1–M4. + +**Reference (verified):** RPC constants in `packages/contracts/src/orchestration.ts`; `Rpc.make({payload,success,error,stream?})` in `rpc.ts`; handlers + `RPC_REQUIRED_SCOPE` in `ws.ts`; client `transport.request` / subscribe in `packages/client-runtime/src/`; zustand `apps/web/src/store.ts`; dnd-kit in `apps/web/src/components/Sidebar.tsx`; CVA in `apps/web/src/components/ui/button.tsx`; tests via `vite-plus/test`. + +--- + +## File Structure + +**Server — create:** +- `apps/server/src/workflow/Services/WorkflowFileLoader.ts` + `Layers/WorkflowFileLoader.ts` + test. +- `apps/server/src/workflow/Services/WorkflowBoardEvents.ts` — a PubSub of board-projection deltas for `subscribeBoard`. +- `apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts` — the `WsRpcGroup` fragment for workflow methods. + +**Server — modify:** +- `packages/contracts/src/workflow.ts` — `WORKFLOW_WS_METHODS`, `Rpc.make` defs, board-snapshot + delta schemas, scopes. +- `apps/server/src/ws.ts` — merge workflow handlers + `RPC_REQUIRED_SCOPE` entries. +- server runtime startup (`serverRuntimeStartup.ts` / `bootstrap.ts`) — provide `WorkflowRuntimeLive`, load board files, run `WorkflowRecovery.recover()`. + +**Web — create:** +- `apps/web/src/workflow/boardState.ts` — zustand slice + reducer for board projection. +- `apps/web/src/workflow/boardRpc.ts` — client calls + subscription wiring. +- `apps/web/src/routes/_chat.$environmentId.board.tsx` — board route. +- `apps/web/src/components/board/BoardView.tsx`, `LaneColumn.tsx`, `TicketCard.tsx`, `TicketDrawer.tsx`, `TicketDiff.tsx`. +- tests: `apps/web/src/workflow/boardState.test.ts`. + +--- + +## Task 1: Contracts — methods, schemas, scopes + +**Files:** Modify `packages/contracts/src/workflow.ts` (and the rpc aggregation file where `Rpc.make` groups live — mirror orchestration). + +- [ ] **Step 1: Add method constants + board read schemas + scopes** + +```typescript +// append to packages/contracts/src/workflow.ts +export const WORKFLOW_WS_METHODS = { + registerBoardFromFile: "workflow.registerBoardFromFile", + getBoard: "workflow.getBoard", + subscribeBoard: "workflow.subscribeBoard", + createTicket: "workflow.createTicket", + moveTicket: "workflow.moveTicket", + runLane: "workflow.runLane", + resolveApproval: "workflow.resolveApproval", + getTicketDetail: "workflow.getTicketDetail", + getTicketDiff: "workflow.getTicketDiff", +} as const; + +// Board snapshot/delta the UI consumes +export const BoardTicketView = Schema.Struct({ + ticketId: TicketId, boardId: BoardId, title: Schema.String, + currentLaneKey: LaneKey, status: TicketStatus, +}); +export const BoardSnapshot = Schema.Struct({ + board: Schema.Struct({ boardId: BoardId, name: Schema.String, + lanes: Schema.Array(Schema.Struct({ key: LaneKey, name: Schema.String, + entry: LaneEntry, terminal: Schema.optional(Schema.Boolean) })) }), + tickets: Schema.Array(BoardTicketView), +}); +export const BoardStreamItem = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: BoardSnapshot }), + Schema.Struct({ kind: Schema.Literal("ticket"), ticket: BoardTicketView }), +]); +export type BoardStreamItem = typeof BoardStreamItem.Type; +``` + +Define auth scopes alongside the existing ones (mirror `AuthOrchestrationReadScope`/`OperateScope`): `AuthWorkflowReadScope`, `AuthWorkflowOperateScope`. + +- [ ] **Step 2: Add `Rpc.make` definitions** (in the file where orchestration RPCs are declared) + +```typescript +export const WsWorkflowSubscribeBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.subscribeBoard, { + payload: Schema.Struct({ boardId: BoardId }), + success: BoardStreamItem, error: Schema.Union([/* WorkflowRpcError */, EnvironmentAuthorizationError]), + stream: true, +}); +export const WsWorkflowCreateTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.createTicket, { + payload: Schema.Struct({ boardId: BoardId, title: Schema.String, + description: Schema.optional(Schema.String), initialLane: LaneKey }), + success: Schema.Struct({ ticketId: TicketId }), + error: Schema.Union([/* WorkflowRpcError */, EnvironmentAuthorizationError]), +}); +export const WsWorkflowMoveTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.moveTicket, { + payload: Schema.Struct({ ticketId: TicketId, toLane: LaneKey }), + success: Schema.Void, error: Schema.Union([/* … */]), +}); +export const WsWorkflowResolveApprovalRpc = Rpc.make(WORKFLOW_WS_METHODS.resolveApproval, { + payload: Schema.Struct({ stepRunId: StepRunId, approved: Schema.Boolean }), + success: Schema.Void, error: Schema.Union([/* … */]), +}); +export const WsWorkflowGetTicketDiffRpc = Rpc.make(WORKFLOW_WS_METHODS.getTicketDiff, { + payload: Schema.Struct({ ticketId: TicketId }), + success: TicketDiff, error: Schema.Union([/* … */]), +}); +// + getBoard, getTicketDetail, runLane, registerBoardFromFile +``` + +Define a `WorkflowRpcError` tagged error (mirror `OrchestrationDispatchCommandError`). + +- [ ] **Step 3: Commit (typecheck the contracts package)** + +```bash +pnpm --filter @t3tools/contracts typecheck +git add packages/contracts/src/workflow.ts packages/contracts/src/rpc.ts +git commit -m "feat(workflow): add workflow.* RPC method + board schemas + scopes" +``` + +--- + +## Task 2: WorkflowFileLoader + +Reads a board file, lints it with **real** provider/file checks, and registers it. + +**Files:** service + layer + test. + +- [ ] **Step 1: Failing test** (stub FS + provider-instance check) + +```typescript +// apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts +// Provide a stub FilePort returning a JSON string, and a providerInstanceExists stub. +// Assert loadAndRegister parses, lints, registers via BoardRegistry, and returns the boardId; +// a lint failure (unknown provider) yields a Left. +``` + +- [ ] **Step 2–4: Implement** + +```typescript +// apps/server/src/workflow/Services/WorkflowFileLoader.ts +import type { BoardId, ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowFileLoaderShape { + readonly loadAndRegister: (input: { + readonly boardId: BoardId; readonly projectId: ProjectId; + readonly filePath: string; readonly repoRoot: string; + }) => Effect.Effect; +} +export class WorkflowFileLoader extends Context.Service< + WorkflowFileLoader, WorkflowFileLoaderShape +>()("t3/workflow/Services/WorkflowFileLoader") {} +``` + +The layer: read file (via `FileSystem` from `@effect/platform` as the rest of the server does) → parse JSON (use a **location-aware** parser for lint diagnostics; a plain `JSON.parse` is acceptable for v1 if you surface schema-cause messages) → `Schema.decodeUnknown(WorkflowDefinition)` → `lintWorkflowDefinition(def, { providerInstanceExists, instructionFileExists })` where: +- `providerInstanceExists` checks the configured provider instances (from `ServerSettings`/`ProviderInstanceRegistry`), +- `instructionFileExists` checks `repoRoot + "/" + step.instruction.file` on disk. +→ compute a `workflowVersionHash` (sha256 of the file content) → `BoardRegistry.register` + `WorkflowReadModel.registerBoard`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/workflow/Services/WorkflowFileLoader.ts apps/server/src/workflow/Layers/WorkflowFileLoader.ts apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts +git commit -m "feat(workflow): add WorkflowFileLoader with real lint checks" +``` + +--- + +## Task 3: Board events PubSub + subscribeBoard data path + +`subscribeBoard` returns a snapshot then live ticket deltas. Emit deltas from the projection step (the committer) into a PubSub. + +**Files:** `WorkflowBoardEvents.ts` (PubSub wrapper) + wire `WorkflowEventCommitter` to publish a `BoardTicketView` after each commit affecting a ticket. + +- [ ] **Step 1–4:** Add a `WorkflowBoardEvents` service exposing `publish(ticketView)` and `stream(boardId)` (a `Stream` filtered by board). In `WorkflowEventCommitter.commit`, after projecting, read the ticket's current `BoardTicketView` from the read model and `publish` it. (Keep this additive; M2's committer test still passes.) +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/workflow/Services/WorkflowBoardEvents.ts apps/server/src/workflow/Layers/WorkflowBoardEvents.ts apps/server/src/workflow/Layers/WorkflowEventCommitter.ts apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts +git commit -m "feat(workflow): publish board ticket deltas for subscriptions" +``` + +--- + +## Task 4: RPC handlers in ws.ts + +**Files:** `apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts` (the handler object) + merge into `ws.ts`. + +- [ ] **Step 1: Write the handlers** (mirror orchestration handler idioms) + +```typescript +// apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts (shape) +import { WORKFLOW_WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +// inside makeWsRpcLayer, given workflowEngine, readModel, ticketDiffQuery, boardEvents, fileLoader: + +export const workflowRpcHandlers = (deps: { + engine; readModel; ticketDiff; boardEvents; fileLoader; observeRpcEffect; observeRpcStreamEffect; +}) => ({ + [WORKFLOW_WS_METHODS.createTicket]: (input) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.createTicket, + deps.engine.createTicket(input).pipe(Effect.map((ticketId) => ({ ticketId }))), + { "rpc.aggregate": "workflow" }), + + [WORKFLOW_WS_METHODS.moveTicket]: (input) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.moveTicket, + deps.engine.moveTicket(input.ticketId, input.toLane), { "rpc.aggregate": "workflow" }), + + [WORKFLOW_WS_METHODS.resolveApproval]: (input) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.resolveApproval, + deps.engine.resolveApproval(input.stepRunId, input.approved), { "rpc.aggregate": "workflow" }), + + [WORKFLOW_WS_METHODS.runLane]: (input) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.runLane, + deps.engine.runLane(input.ticketId), { "rpc.aggregate": "workflow" }), + + [WORKFLOW_WS_METHODS.getTicketDiff]: (input) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getTicketDiff, + deps.readModel.getTicketDetail(input.ticketId).pipe( + Effect.flatMap((d) => deps.ticketDiff.getTicketDiff( + input.ticketId, /* worktree cwd */ d!.ticket /* resolve cwd */, /* baseRef */ "")), + ), { "rpc.aggregate": "workflow" }), + + [WORKFLOW_WS_METHODS.subscribeBoard]: (input) => + deps.observeRpcStreamEffect(WORKFLOW_WS_METHODS.subscribeBoard, + Effect.gen(function* () { + const board = yield* deps.readModel.getBoard(input.boardId); + const tickets = yield* deps.readModel.listTickets(input.boardId); + const snapshot = { kind: "snapshot" as const, + snapshot: { board: /* shape board+lanes */ board, tickets } }; + const live = deps.boardEvents.stream(input.boardId).pipe( + Stream.map((ticket) => ({ kind: "ticket" as const, ticket }))); + return Stream.concat(Stream.make(snapshot), live); + }), { "rpc.aggregate": "workflow" }), + // + getBoard, getTicketDetail, registerBoardFromFile +}); +``` + +- [ ] **Step 2: Merge into `ws.ts`** — spread `workflowRpcHandlers({...})` into the `WsRpcGroup.of({...})` object; obtain the workflow services from the runtime (they’re provided by `WorkflowRuntimeLive`). Add `RPC_REQUIRED_SCOPE` entries: + +```typescript +[WORKFLOW_WS_METHODS.subscribeBoard, AuthWorkflowReadScope], +[WORKFLOW_WS_METHODS.getBoard, AuthWorkflowReadScope], +[WORKFLOW_WS_METHODS.getTicketDetail, AuthWorkflowReadScope], +[WORKFLOW_WS_METHODS.getTicketDiff, AuthWorkflowReadScope], +[WORKFLOW_WS_METHODS.createTicket, AuthWorkflowOperateScope], +[WORKFLOW_WS_METHODS.moveTicket, AuthWorkflowOperateScope], +[WORKFLOW_WS_METHODS.runLane, AuthWorkflowOperateScope], +[WORKFLOW_WS_METHODS.resolveApproval, AuthWorkflowOperateScope], +[WORKFLOW_WS_METHODS.registerBoardFromFile, AuthWorkflowOperateScope], +``` + +- [ ] **Step 3: Wire `WorkflowRuntimeLive` into the server runtime** and run recovery at startup (mirror the existing reactor startup in `serverRuntimeStartup.ts`): provide the layer, then `yield* (yield* WorkflowRecovery).recover()` once during boot. + +- [ ] **Step 4: Typecheck + a thin server RPC test** (if the repo has ws handler tests, mirror them; otherwise rely on the web e2e in Task 8). Commit. + +```bash +git add apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts apps/server/src/ws.ts apps/server/src/serverRuntimeStartup.ts +git commit -m "feat(workflow): wire workflow.* RPC handlers + runtime + recovery at startup" +``` + +--- + +## Task 5: Web — board state slice + subscription + +**Files:** `apps/web/src/workflow/boardState.ts` + `boardRpc.ts` + `boardState.test.ts`. + +- [ ] **Step 1: Failing test** (`vite-plus/test`) + +```typescript +// apps/web/src/workflow/boardState.test.ts +import { describe, expect, it } from "vite-plus/test"; +import { applyBoardStreamItem, emptyBoardState } from "./boardState.ts"; + +describe("boardState", () => { + it("applies a snapshot then a ticket delta", () => { + let s = applyBoardStreamItem(emptyBoardState, { + kind: "snapshot", + snapshot: { board: { boardId: "b-1", name: "Delivery", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }] }, + tickets: [{ ticketId: "t-1", boardId: "b-1", title: "X", + currentLaneKey: "backlog", status: "idle" }] }, + } as never); + expect(s.ticketIds).toEqual(["t-1"]); + + s = applyBoardStreamItem(s, { kind: "ticket", + ticket: { ticketId: "t-1", boardId: "b-1", title: "X", + currentLaneKey: "done", status: "done" } } as never); + expect(s.ticketById["t-1"].currentLaneKey).toBe("done"); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement the reducer + slice** + +```typescript +// apps/web/src/workflow/boardState.ts +import type { BoardStreamItem } from "@t3tools/contracts"; + +export interface BoardState { + readonly boardId: string | null; + readonly boardName: string; + readonly lanes: ReadonlyArray<{ key: string; name: string; entry: string; terminal?: boolean }>; + readonly ticketIds: ReadonlyArray; + readonly ticketById: Record; +} + +export const emptyBoardState: BoardState = { + boardId: null, boardName: "", lanes: [], ticketIds: [], ticketById: {}, +}; + +export const applyBoardStreamItem = (state: BoardState, item: BoardStreamItem): BoardState => { + if (item.kind === "snapshot") { + const ticketById: BoardState["ticketById"] = {}; + for (const t of item.snapshot.tickets) ticketById[t.ticketId] = { + ticketId: t.ticketId, title: t.title, currentLaneKey: t.currentLaneKey, status: t.status }; + return { + boardId: item.snapshot.board.boardId, boardName: item.snapshot.board.name, + lanes: item.snapshot.board.lanes.map((l) => ({ ...l })), + ticketIds: item.snapshot.tickets.map((t) => t.ticketId), ticketById, + }; + } + // ticket delta + const t = item.ticket; + const exists = state.ticketById[t.ticketId] !== undefined; + return { + ...state, + ticketIds: exists ? state.ticketIds : [...state.ticketIds, t.ticketId], + ticketById: { ...state.ticketById, [t.ticketId]: { + ticketId: t.ticketId, title: t.title, currentLaneKey: t.currentLaneKey, status: t.status } }, + }; +}; +``` + +Add a zustand slice (mirror `apps/web/src/store.ts`) holding `BoardState` per board and an action `applyBoardStreamItem`. + +- [ ] **Step 4: Subscription wiring** in `boardRpc.ts` — mirror `environments/runtime/service.ts`: + +```typescript +// apps/web/src/workflow/boardRpc.ts (shape) +import { WORKFLOW_WS_METHODS } from "@t3tools/contracts"; +// connection.client.workflow.subscribeBoard({ boardId }, (item) => store.applyBoardStreamItem(item)) +// and request helpers: +export const createTicket = (rpc, input) => rpc.request((c) => c.workflow.createTicket(input)); +export const moveTicket = (rpc, ticketId, toLane) => + rpc.request((c) => c.workflow.moveTicket({ ticketId, toLane })); +export const resolveApproval = (rpc, stepRunId, approved) => + rpc.request((c) => c.workflow.resolveApproval({ stepRunId, approved })); +export const getTicketDiff = (rpc, ticketId) => + rpc.request((c) => c.workflow.getTicketDiff({ ticketId })); +``` + +> Confirm the generated client exposes a `workflow` namespace (it’s derived from the `Rpc.make` group registration; ensure the workflow RPCs are added to the same client group as `orchestration`). + +- [ ] **Step 5: Run → PASS. Commit.** + +```bash +git add apps/web/src/workflow/boardState.ts apps/web/src/workflow/boardRpc.ts apps/web/src/workflow/boardState.test.ts apps/web/src/store.ts +git commit -m "feat(web): board state slice + subscription wiring" +``` + +--- + +## Task 6: Web — Board view (lanes + drag-able cards) + +**Files:** `components/board/BoardView.tsx`, `LaneColumn.tsx`, `TicketCard.tsx`; route `routes/_chat.$environmentId.board.tsx`. + +- [ ] **Step 1: Card + lane components** (Tailwind + CVA; status badge logic) + +```tsx +// apps/web/src/components/board/TicketCard.tsx +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +export function TicketCard({ ticket, onOpen }: { + ticket: { ticketId: string; title: string; status: string }; + onOpen: (id: string) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: ticket.ticketId }); + const style = { transform: CSS.Transform.toString(transform), transition, + opacity: isDragging ? 0.5 : 1 }; + const badge = + ticket.status === "waiting_on_user" ? "⏸ waiting on you" : + ticket.status === "running" ? "running" : + ticket.status === "blocked" || ticket.status === "failed" ? "⚠ blocked" : + ticket.status === "done" ? "✓ done" : null; + return ( +
onOpen(ticket.ticketId)} + className="cursor-pointer rounded-md border border-border bg-card p-2 text-sm hover:bg-accent/40"> +
{ticket.title}
+ {badge &&
{badge}
} +
+ ); +} +``` + +```tsx +// apps/web/src/components/board/LaneColumn.tsx +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useDroppable } from "@dnd-kit/core"; +import { TicketCard } from "./TicketCard.tsx"; + +export function LaneColumn({ lane, tickets, onOpen }: { + lane: { key: string; name: string; entry: string }; + tickets: ReadonlyArray<{ ticketId: string; title: string; status: string }>; + onOpen: (id: string) => void; +}) { + const { setNodeRef } = useDroppable({ id: `lane:${lane.key}` }); + return ( +
+
+ {lane.name}{lane.entry} · {tickets.length} +
+ t.ticketId)} strategy={verticalListSortingStrategy}> + {tickets.map((t) => )} + +
+ ); +} +``` + +```tsx +// apps/web/src/components/board/BoardView.tsx +import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors, closestCorners } from "@dnd-kit/core"; +import { LaneColumn } from "./LaneColumn.tsx"; + +export function BoardView({ state, onMove, onOpen }: { + state: { lanes: ReadonlyArray<{ key: string; name: string; entry: string }>; + ticketIds: ReadonlyArray; + ticketById: Record }; + onMove: (ticketId: string, toLane: string) => void; + onOpen: (id: string) => void; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + const onDragEnd = (e: DragEndEvent) => { + const ticketId = String(e.active.id); + const overId = e.over ? String(e.over.id) : null; + if (overId?.startsWith("lane:")) onMove(ticketId, overId.slice("lane:".length)); + }; + return ( + +
+ {state.lanes.map((lane) => ( + state.ticketById[id]).filter((t) => t.currentLaneKey === lane.key)} /> + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Route** — `routes/_chat.$environmentId.board.tsx` using `createFileRoute` (mirror an existing route); on mount, subscribe via `boardRpc`, read the board slice, render `moveTicket(rpc,id,lane)} onOpen={openDrawer}/>`. + +- [ ] **Step 3: Manual verify** — start the app, open the board route, confirm lanes/cards render and dragging a card to another lane calls `moveTicket` (use the Argent/iOS tooling or a browser per your workflow; or a `vite-plus` browser test if available). + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/board/ apps/web/src/routes/_chat.$environmentId.board.tsx +git commit -m "feat(web): board view with dnd-kit lanes and ticket cards" +``` + +--- + +## Task 7: Web — Ticket drill-in (timeline, activity, question inbox, diff) + +**Files:** `components/board/TicketDrawer.tsx`, `TicketDiff.tsx`. + +- [ ] **Step 1: TicketDrawer** — fetches `getTicketDetail` (step timeline + statuses) and renders: + - step list (each StepRun: agent/model, status, ⏸ when `awaiting_user`), + - the active step's live agent activity by **reusing the existing thread rendering** (the StepRun's `threadId` from M3 → render the same component the chat view uses), + - a question/approval inbox: when a step is `awaiting_user`, show Approve/Reject (and a text reply for provider questions) that calls `resolveApproval(rpc, stepRunId, approved)`, + - `` (Task 7 Step 2), + - controls: Run (`runLane`), Abort (deferred per spec — render disabled or omit), Move (a lane menu calling `moveTicket`). + +```tsx +// apps/web/src/components/board/TicketDrawer.tsx (shape) +export function TicketDrawer({ detail, onApprove, onRunLane }: { + detail: { ticket: { title: string; currentLaneKey: string; status: string }; + steps: ReadonlyArray<{ stepRunId: string; stepKey: string; stepType: string; + status: string; waitingReason: string | null }> }; + onApprove: (stepRunId: string, approved: boolean) => void; + onRunLane: () => void; +}) { + return ( + + ); +} +``` + +- [ ] **Step 2: TicketDiff** — calls `getTicketDiff(rpc, ticketId)` and renders the file summaries + patch using the **existing diff rendering** the app already uses for checkpoints (reuse `DiffPanel`/the `@pierre/diffs` viewer the chat diff view uses, feeding it `diff.patch`). + +- [ ] **Step 3: Manual verify** — create a ticket via the board, watch it run (mock or real provider), confirm a question parks the card as `⏸ waiting on you`, open the drawer, Approve, and confirm the pipeline resumes and the diff shows accumulated changes. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/board/TicketDrawer.tsx apps/web/src/components/board/TicketDiff.tsx +git commit -m "feat(web): ticket drill-in with timeline, question inbox, and diff" +``` + +--- + +## Task 8: End-to-end smoke + board creation UX + +- [ ] **Step 1:** Add a minimal "New ticket" affordance on the board (title + initial lane → `createTicket`). +- [ ] **Step 2:** Add a board file at `.t3/boards/delivery.json` (the §6 example) and a startup/registration path (loader runs for discovered board files, or a "Register board" action invoking `registerBoardFromFile`). +- [ ] **Step 3:** Full manual run-through against a real project + provider instance: register board → create ticket in an `auto` lane → agent step runs in a worktree → (optional) approval → routes to done → drawer shows accumulated diff. Capture a screenshot. +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/board/ .t3/boards/delivery.json +git commit -m "feat(web): new-ticket affordance + sample board file + e2e smoke" +``` + +--- + +## Milestone Done — Definition of Done (v1 complete) + +- [ ] `pnpm --filter @t3tools/server test`, `pnpm --filter @t3tools/contracts test`, and `pnpm --filter @t3tools/web test` green; all packages typecheck. +- [ ] A board file in the repo becomes a live board; tickets are created manually; agent pipelines run in per-ticket worktrees; the board shows live status incl. `⏸ waiting on you`; clicking a ticket shows the step timeline, live agent activity, the question/approval inbox (answered inline to resume), and the accumulated diff. +- [ ] Manual drag re-routes a ticket and supersedes an in-flight pipeline; a server restart recovers in-flight work without duplicate dispatch. +- [ ] **v1 is feature-complete.** Next: v2 (script steps, predicates, per-step routing, WIP enforcement, visual editor) — see `docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md`. + +--- + +## Notes for the implementer + +- **Reuse existing UI building blocks:** the drawer should render the active step's thread with the **same** component the chat view uses (the StepRun carries a `threadId`), and the diff with the **same** `@pierre/diffs`-based viewer used for checkpoints. Don't build new renderers. +- **Client RPC namespace:** ensure the `workflow.*` `Rpc.make` definitions are registered into the same client group as `orchestration.*` so the generated client exposes `client.workflow.*`. Confirm the registration site in contracts/client-runtime. +- **Diff cwd/baseRef:** `getTicketDiff` needs the ticket's worktree path and `baselineRef`; resolve the worktree cwd from `worktreeRef` (the VCS layer maps ref→path) and read `baseRef` as `ticketBaseRef(ticketId)`. +- **Auth scopes:** add `AuthWorkflowReadScope`/`AuthWorkflowOperateScope` to the same scope registry orchestration uses, and to any default role grants so the UI can call them. +- **Abort:** v1 defers `pause`; `abort` is defined in the spec (§8) but if not yet implemented end-to-end, render it disabled rather than wiring a partial path. From 93df8b0959a2ba1498d7bcf8e141b3d3c562be60 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 01:44:30 -0400 Subject: [PATCH 007/295] docs: apply GPT-5.5 code-verified corrections to workflow boards plans An agentic opencode (GPT-5.5 fast, xhigh) pass reviewed M2-M5 against the actual codebase and corrected signatures/idioms in place: - M2: real Effect 4 beta APIs (Schema.TaggedErrorClass, Effect.forkDetach, Fiber.join, atomic semaphore), camelCase token read-model fields. - M3: actual ProviderService/ProjectionTurnRepository/ProjectSetupScriptRunner/ TerminalManager signatures, outbox states pending|started|confirmed, lease release via Effect.ensuring, provider-response requestId. - M4: public GitVcsDriver.execute (not internal executeGit), untracked-file diffs, exported CheckpointRef, no importing local test helpers. - M5: full RPC wiring (rpc.ts -> scopes -> ws.ts -> server.ts -> WsRpcClient -> EnvironmentApi) and dnd-kit lane-drop resolution. M1 and the v1/v2 specs are formatting-only (vp fmt). Verified: vp check and vp typecheck pass; changes confined to docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-07-workflow-boards-v1-m1-foundation.md | 478 +++++++++++++----- ...06-07-workflow-boards-v1-m2-engine-core.md | 350 +++++++++---- ...workflow-boards-v1-m3-durable-execution.md | 384 +++++++++----- ...06-07-workflow-boards-v1-m4-ticket-diff.md | 189 +++++-- ...2026-06-07-workflow-boards-v1-m5-rpc-ui.md | 435 ++++++++++++---- .../2026-06-06-workflow-boards-v1-design.md | 60 ++- .../2026-06-06-workflow-boards-v2-design.md | 38 +- 7 files changed, 1364 insertions(+), 570 deletions(-) diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md index 7f75bf8ef61..10d32a7fa4d 100644 --- a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md @@ -29,6 +29,7 @@ ## File Structure **Create:** + - `packages/contracts/src/workflow.ts` — all workflow schemas: branded IDs, tokens, workflow-file definition (lanes/steps), workflow events, projection row types. - `apps/server/src/persistence/Migrations/0XX_WorkflowEvents.ts` — `workflow_events` + projection tables migration (number assigned in Task 5). - `apps/server/src/workflow/Services/WorkflowEventStore.ts` — event store interface. @@ -45,6 +46,7 @@ - `apps/server/src/workflow/WorkflowFoundationLive.ts` — aggregated layer for this milestone. **Modify:** + - `apps/server/src/persistence/Migrations.ts` — register the new migration. --- @@ -52,6 +54,7 @@ ## Task 1: Workflow branded IDs and tokens (contracts) **Files:** + - Create: `packages/contracts/src/workflow.ts` - Test: `packages/contracts/src/workflow.test.ts` @@ -71,7 +74,7 @@ describe("workflow ids", () => { it("rejects an empty ticket id", () => { const decode = Schema.decodeUnknownEither(TicketId); - assert.isTrue(decode(""). _tag === "Left"); + assert.isTrue(decode("")._tag === "Left"); }); it("brands lane-entry tokens and event ids", () => { @@ -141,6 +144,7 @@ git commit -m "feat(workflow): add branded ids and keys for workflow boards" Models the `.t3/boards/.json` file (§6): lanes, per-lane entry, pipeline of `agent`/`approval` steps, lane-level routing. **Files:** + - Modify: `packages/contracts/src/workflow.ts` - Test: `packages/contracts/src/workflow.test.ts` @@ -158,12 +162,22 @@ describe("WorkflowDefinition", () => { lanes: [ { key: "backlog", name: "Backlog", entry: "manual" }, { - key: "implement", name: "Implement", entry: "auto", + key: "implement", + name: "Implement", + entry: "auto", pipeline: [ - { key: "code", type: "agent", agent: { instance: "claude_main", model: "sonnet" }, - instruction: { file: "prompts/implement.md" } }, - { key: "review", type: "agent", agent: { instance: "codex_main", model: "gpt-5.4" }, - instruction: "Review the diff." }, + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/implement.md" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.4" }, + instruction: "Review the diff.", + }, ], on: { success: "owner_review", failure: "needs_attention" }, }, @@ -183,8 +197,14 @@ describe("WorkflowDefinition", () => { it("rejects an unknown step type", () => Schema.decodeUnknown(WorkflowDefinition)({ name: "x", - lanes: [{ key: "a", name: "A", entry: "auto", - pipeline: [{ key: "s", type: "script", run: "echo hi" }] }], + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "s", type: "script", run: "echo hi" }], + }, + ], }).pipe( Effect.match({ onFailure: () => "rejected", onSuccess: () => "accepted" }), Effect.map((r) => assert.equal(r, "rejected")), @@ -286,6 +306,7 @@ git commit -m "feat(workflow): add workflow file definition schema (lanes/steps/ Pure validation beyond schema decode (§6): duplicate keys, routing targets to missing lanes, `auto`-lane cycles with no human/terminal break, unknown provider instances, missing instruction files. Provider/file existence are injected so the linter stays pure and testable. **Files:** + - Create: `apps/server/src/workflow/workflowFile.ts` - Test: `apps/server/src/workflow/workflowFile.test.ts` @@ -298,7 +319,7 @@ import type { WorkflowDefinition } from "@t3tools/contracts"; import { lintWorkflowDefinition } from "./workflowFile.ts"; const base = (lanes: unknown): WorkflowDefinition => - ({ name: "wf", lanes } as unknown as WorkflowDefinition); + ({ name: "wf", lanes }) as unknown as WorkflowDefinition; const ctx = { providerInstanceExists: (id: string) => id === "claude_main", @@ -309,11 +330,20 @@ describe("lintWorkflowDefinition", () => { it("passes a valid definition", () => { const errors = lintWorkflowDefinition( base([ - { key: "a", name: "A", entry: "auto", - pipeline: [{ key: "s", type: "agent", - agent: { instance: "claude_main", model: "sonnet" }, - instruction: { file: "prompts/ok.md" } }], - on: { success: "done" } }, + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + on: { success: "done" }, + }, { key: "done", name: "Done", entry: "manual", terminal: true }, ]), ctx, @@ -342,9 +372,16 @@ describe("lintWorkflowDefinition", () => { it("flags an unknown provider instance", () => { const errors = lintWorkflowDefinition( - base([{ key: "a", name: "A", entry: "auto", - pipeline: [{ key: "s", type: "agent", - agent: { instance: "nope", model: "x" }, instruction: "hi" }] }]), + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { key: "s", type: "agent", agent: { instance: "nope", model: "x" }, instruction: "hi" }, + ], + }, + ]), ctx, ); assert.isTrue(errors.some((e) => e.code === "unknown_provider_instance")); @@ -352,10 +389,21 @@ describe("lintWorkflowDefinition", () => { it("flags a missing instruction file", () => { const errors = lintWorkflowDefinition( - base([{ key: "a", name: "A", entry: "auto", - pipeline: [{ key: "s", type: "agent", - agent: { instance: "claude_main", model: "x" }, - instruction: { file: "prompts/missing.md" } }] }]), + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "prompts/missing.md" }, + }, + ], + }, + ]), ctx, ); assert.isTrue(errors.some((e) => e.code === "missing_instruction_file")); @@ -409,9 +457,7 @@ export interface LintContext { const routingTargets = (lane: WorkflowLane): ReadonlyArray => { const on = lane.on; if (!on) return []; - return [on.success, on.failure, on.blocked].filter( - (t): t is string => typeof t === "string", - ); + return [on.success, on.failure, on.blocked].filter((t): t is string => typeof t === "string"); }; export const lintWorkflowDefinition = ( @@ -425,8 +471,11 @@ export const lintWorkflowDefinition = ( for (const lane of def.lanes) { const key = lane.key as string; if (laneKeys.has(key)) { - errors.push({ code: "duplicate_lane_key", laneKey: key, - message: `Duplicate lane key "${key}"` }); + errors.push({ + code: "duplicate_lane_key", + laneKey: key, + message: `Duplicate lane key "${key}"`, + }); } laneKeys.add(key); @@ -434,27 +483,44 @@ export const lintWorkflowDefinition = ( for (const step of lane.pipeline ?? []) { const sk = step.key as string; if (stepKeys.has(sk)) { - errors.push({ code: "duplicate_step_key", laneKey: key, stepKey: sk, - message: `Duplicate step key "${sk}" in lane "${key}"` }); + errors.push({ + code: "duplicate_step_key", + laneKey: key, + stepKey: sk, + message: `Duplicate step key "${sk}" in lane "${key}"`, + }); } stepKeys.add(sk); if (step.type === "agent") { if (!ctx.providerInstanceExists(step.agent.instance)) { - errors.push({ code: "unknown_provider_instance", laneKey: key, stepKey: sk, - message: `Unknown provider instance "${step.agent.instance}"` }); + errors.push({ + code: "unknown_provider_instance", + laneKey: key, + stepKey: sk, + message: `Unknown provider instance "${step.agent.instance}"`, + }); } - if (typeof step.instruction === "object" && - !ctx.instructionFileExists(step.instruction.file)) { - errors.push({ code: "missing_instruction_file", laneKey: key, stepKey: sk, - message: `Instruction file not found: "${step.instruction.file}"` }); + if ( + typeof step.instruction === "object" && + !ctx.instructionFileExists(step.instruction.file) + ) { + errors.push({ + code: "missing_instruction_file", + laneKey: key, + stepKey: sk, + message: `Instruction file not found: "${step.instruction.file}"`, + }); } } } for (const target of routingTargets(lane)) { if (!allKeys.includes(target)) { - errors.push({ code: "missing_lane_ref", laneKey: key, - message: `Lane "${key}" routes to missing lane "${target}"` }); + errors.push({ + code: "missing_lane_ref", + laneKey: key, + message: `Lane "${key}" routes to missing lane "${target}"`, + }); } } } @@ -469,8 +535,11 @@ export const lintWorkflowDefinition = ( while (cursor && cursor.entry === "auto" && !cursor.terminal) { const ck = cursor.key as string; if (seen.has(ck)) { - errors.push({ code: "auto_lane_cycle", laneKey: lane.key as string, - message: `Auto-lane cycle detected starting at "${lane.key}"` }); + errors.push({ + code: "auto_lane_cycle", + laneKey: lane.key as string, + message: `Auto-lane cycle detected starting at "${lane.key}"`, + }); break; } seen.add(ck); @@ -502,6 +571,7 @@ git commit -m "feat(workflow): add pure workflow-file linter" The foundation's projections consume these. Event base fields mirror the orchestration store columns (event id, stream id = ticket id, version, occurredAt, payload). **Files:** + - Modify: `packages/contracts/src/workflow.ts` - Test: `packages/contracts/src/workflow.test.ts` @@ -530,8 +600,11 @@ describe("WorkflowEvent", () => { it("decodes a TicketMovedToLane event", () => Effect.gen(function* () { const e = yield* Schema.decodeUnknown(WorkflowEvent)({ - type: "TicketMovedToLane", eventId: "evt-2", ticketId: "t-1", - streamVersion: 1, occurredAt: "2026-06-07T00:00:01.000Z", + type: "TicketMovedToLane", + eventId: "evt-2", + ticketId: "t-1", + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z", payload: { toLane: "implement", laneEntryToken: "tok-1", reason: "manual" }, }); assert.equal(e.type, "TicketMovedToLane"); @@ -552,60 +625,119 @@ import { IsoDateTime } from "./baseSchemas.ts"; const EventBase = { eventId: WorkflowEventId, - ticketId: TicketId, // stream id for v1 is always the ticket + ticketId: TicketId, // stream id for v1 is always the ticket streamVersion: Schema.Int, occurredAt: IsoDateTime, }; export const TicketStatus = Schema.Literal( - "idle", "running", "waiting_on_user", "blocked", "done", "failed", + "idle", + "running", + "waiting_on_user", + "blocked", + "done", + "failed", ); export type TicketStatus = typeof TicketStatus.Type; export const StepRunStatus = Schema.Literal( - "pending", "dispatch_requested", "running", "awaiting_user", - "completed", "failed", "superseded", + "pending", + "dispatch_requested", + "running", + "awaiting_user", + "completed", + "failed", + "superseded", ); export type StepRunStatus = typeof StepRunStatus.Type; export const WorkflowEvent = Schema.Union([ - Schema.Struct({ ...EventBase, type: Schema.Literal("TicketCreated"), - payload: Schema.Struct({ boardId: BoardId, title: TrimmedNonEmptyString, - laneKey: LaneKey, description: Schema.optional(Schema.String) }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("TicketMovedToLane"), - payload: Schema.Struct({ toLane: LaneKey, laneEntryToken: LaneEntryToken, - reason: Schema.Literal("manual", "routed", "initial") }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("TicketBlocked"), - payload: Schema.Struct({ reason: Schema.String }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("PipelineStarted"), - payload: Schema.Struct({ pipelineRunId: PipelineRunId, laneKey: LaneKey, - laneEntryToken: LaneEntryToken }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("PipelineCompleted"), - payload: Schema.Struct({ pipelineRunId: PipelineRunId, - result: Schema.Literal("success", "failure", "blocked", "superseded") }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("StepStarted"), - payload: Schema.Struct({ pipelineRunId: PipelineRunId, stepRunId: StepRunId, - stepKey: StepKey, stepType: Schema.Literal("agent", "approval") }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("StepAwaitingUser"), - payload: Schema.Struct({ stepRunId: StepRunId, waitingReason: Schema.String }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("StepUserResolved"), - payload: Schema.Struct({ stepRunId: StepRunId }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("StepCompleted"), - payload: Schema.Struct({ stepRunId: StepRunId }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("StepFailed"), - payload: Schema.Struct({ stepRunId: StepRunId, error: Schema.String }) }), - - Schema.Struct({ ...EventBase, type: Schema.Literal("TicketRouted"), - payload: Schema.Struct({ fromLane: LaneKey, toLane: LaneKey }) }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketCreated"), + payload: Schema.Struct({ + boardId: BoardId, + title: TrimmedNonEmptyString, + laneKey: LaneKey, + description: Schema.optional(Schema.String), + }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketMovedToLane"), + payload: Schema.Struct({ + toLane: LaneKey, + laneEntryToken: LaneEntryToken, + reason: Schema.Literal("manual", "routed", "initial"), + }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketBlocked"), + payload: Schema.Struct({ reason: Schema.String }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("PipelineStarted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + laneKey: LaneKey, + laneEntryToken: LaneEntryToken, + }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("PipelineCompleted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + result: Schema.Literal("success", "failure", "blocked", "superseded"), + }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepStarted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + stepRunId: StepRunId, + stepKey: StepKey, + stepType: Schema.Literal("agent", "approval"), + }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepAwaitingUser"), + payload: Schema.Struct({ stepRunId: StepRunId, waitingReason: Schema.String }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepUserResolved"), + payload: Schema.Struct({ stepRunId: StepRunId }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepCompleted"), + payload: Schema.Struct({ stepRunId: StepRunId }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepFailed"), + payload: Schema.Struct({ stepRunId: StepRunId, error: Schema.String }), + }), + + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketRouted"), + payload: Schema.Struct({ fromLane: LaneKey, toLane: LaneKey }), + }), ]); export type WorkflowEvent = typeof WorkflowEvent.Type; ``` @@ -627,6 +759,7 @@ git commit -m "feat(workflow): add workflow event union" ## Task 5: Migration — workflow tables **Files:** + - Create: `apps/server/src/persistence/Migrations/0XX_WorkflowEvents.ts` (assign the next free number — check `Migrations.ts` `migrationEntries`; the spec assumes ~033+). - Modify: `apps/server/src/persistence/Migrations.ts` - Test: `apps/server/src/workflow/Layers/WorkflowEventStore.test.ts` (created here, asserts tables exist; expanded in Task 6). @@ -782,6 +915,7 @@ git commit -m "feat(workflow): add workflow_events and projection tables migrati Append (with per-ticket optimistic versioning) and stream reads. Mirrors `OrchestrationEventStore`. **Files:** + - Create: `apps/server/src/workflow/Services/WorkflowEventStore.ts` - Create: `apps/server/src/workflow/Layers/WorkflowEventStore.ts` - Modify: `apps/server/src/workflow/Layers/WorkflowEventStore.test.ts` @@ -825,12 +959,20 @@ storeLayer("WorkflowEventStore", (it) => { it.effect("assigns incrementing stream versions per ticket", () => Effect.gen(function* () { const store = yield* WorkflowEventStore; - yield* store.append({ type: "TicketCreated", eventId: "evt-b" as never, - ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never, - payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "backlog" as never } }); - const second = yield* store.append({ type: "TicketBlocked", eventId: "evt-c" as never, - ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:01.000Z" as never, - payload: { reason: "scope unclear" } }); + yield* store.append({ + type: "TicketCreated", + eventId: "evt-b" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "backlog" as never }, + }); + const second = yield* store.append({ + type: "TicketBlocked", + eventId: "evt-c" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "scope unclear" }, + }); assert.equal(second.streamVersion, 1); }), ); @@ -927,8 +1069,8 @@ const decodeEvent = (row: Row): Effect.Effect ({ ...e, sequence: row.sequence }) as PersistedWorkflowEvent), - Effect.mapError((cause) => - new WorkflowEventStoreError({ message: "Failed to decode workflow event", cause }), + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "Failed to decode workflow event", cause }), ), ); @@ -970,9 +1112,7 @@ const make = Effect.gen(function* () { ): Stream.Stream => Stream.fromIterableEffect( query.pipe( - Effect.mapError((cause) => - new WorkflowEventStoreError({ message: "read failed", cause }), - ), + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "read failed", cause })), ), ).pipe(Stream.mapEffect(decodeEvent)); @@ -1023,6 +1163,7 @@ git commit -m "feat(workflow): add WorkflowEventStore with per-ticket versioning Applies events to `projection_ticket` / `projection_pipeline_run` / `projection_step_run`. `projection_board` rows are written by a board-registration call (Task 8) — projections here cover ticket/run state. **Files:** + - Create: `apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts` - Create: `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts` - Create: `apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts` @@ -1054,16 +1195,28 @@ layer("WorkflowProjectionPipeline", (it) => { const sql = yield* SqlClient.SqlClient; yield* pipeline.projectEvent({ - type: "TicketCreated", eventId: "e1" as never, ticketId: "t-1" as never, - streamVersion: 0, occurredAt: "2026-06-07T00:00:00.000Z" as never, - payload: { boardId: "b-1" as never, title: "Export CSV" as never, - laneKey: "backlog" as never }, + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Export CSV" as never, + laneKey: "backlog" as never, + }, }); yield* pipeline.projectEvent({ - type: "TicketMovedToLane", eventId: "e2" as never, ticketId: "t-1" as never, - streamVersion: 1, occurredAt: "2026-06-07T00:00:01.000Z" as never, - payload: { toLane: "implement" as never, laneEntryToken: "tok-1" as never, - reason: "routed" }, + type: "TicketMovedToLane", + eventId: "e2" as never, + ticketId: "t-1" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-1" as never, + reason: "routed", + }, }); const rows = yield* sql<{ readonly currentLaneKey: string; readonly status: string }>` @@ -1081,17 +1234,43 @@ layer("WorkflowProjectionPipeline", (it) => { const sql = yield* SqlClient.SqlClient; const base = { ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; - yield* pipeline.projectEvent({ ...base, type: "TicketCreated", eventId: "a" as never, - streamVersion: 0, payload: { boardId: "b-1" as never, title: "Y" as never, - laneKey: "implement" as never } }); - yield* pipeline.projectEvent({ ...base, type: "PipelineStarted", eventId: "b" as never, - streamVersion: 1, payload: { pipelineRunId: "pr-1" as never, - laneKey: "implement" as never, laneEntryToken: "tok-1" as never } }); - yield* pipeline.projectEvent({ ...base, type: "StepStarted", eventId: "c" as never, - streamVersion: 2, payload: { pipelineRunId: "pr-1" as never, stepRunId: "sr-1" as never, - stepKey: "code" as never, stepType: "agent" } }); - yield* pipeline.projectEvent({ ...base, type: "StepAwaitingUser", eventId: "d" as never, - streamVersion: 3, payload: { stepRunId: "sr-1" as never, waitingReason: "which API?" } }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-1" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-1" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "d" as never, + streamVersion: 3, + payload: { stepRunId: "sr-1" as never, waitingReason: "which API?" }, + }); const ticket = yield* sql<{ readonly status: string }>` SELECT status FROM projection_ticket WHERE ticket_id = 't-2'`; @@ -1121,9 +1300,7 @@ import type * as Effect from "effect/Effect"; import type { WorkflowEventStoreError } from "./Errors.ts"; export interface WorkflowProjectionPipelineShape { - readonly projectEvent: ( - event: WorkflowEvent, - ) => Effect.Effect; + readonly projectEvent: (event: WorkflowEvent) => Effect.Effect; } export class WorkflowProjectionPipeline extends Context.Service< @@ -1286,6 +1463,7 @@ git commit -m "feat(workflow): add board/ticket projection pipeline" Read-side queries the UI/engine use: register a board, get board, list tickets, get ticket detail (ticket + its step runs). **Files:** + - Create: `apps/server/src/workflow/Services/WorkflowReadModel.ts` - Create: `apps/server/src/workflow/Layers/WorkflowReadModel.ts` - Create: `apps/server/src/workflow/Layers/WorkflowReadModel.test.ts` @@ -1318,15 +1496,21 @@ layer("WorkflowReadModel", (it) => { const pipeline = yield* WorkflowProjectionPipeline; yield* read.registerBoard({ - boardId: "b-1" as never, projectId: "p-1" as never, name: "Delivery", - workflowFilePath: ".t3/boards/delivery.json", workflowVersionHash: "hash1", + boardId: "b-1" as never, + projectId: "p-1" as never, + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash1", maxConcurrentTickets: 3, }); - yield* pipeline.projectEvent({ type: "TicketCreated", eventId: "e1" as never, - ticketId: "t-1" as never, streamVersion: 0, + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, occurredAt: "2026-06-07T00:00:00.000Z" as never, - payload: { boardId: "b-1" as never, title: "Export" as never, - laneKey: "backlog" as never } }); + payload: { boardId: "b-1" as never, title: "Export" as never, laneKey: "backlog" as never }, + }); const board = yield* read.getBoard("b-1" as never); assert.equal(board?.name, "Delivery"); @@ -1341,15 +1525,36 @@ layer("WorkflowReadModel", (it) => { const read = yield* WorkflowReadModel; const pipeline = yield* WorkflowProjectionPipeline; const base = { ticketId: "t-9" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; - yield* pipeline.projectEvent({ ...base, type: "TicketCreated", eventId: "a" as never, - streamVersion: 0, payload: { boardId: "b-1" as never, title: "Z" as never, - laneKey: "implement" as never } }); - yield* pipeline.projectEvent({ ...base, type: "PipelineStarted", eventId: "b" as never, - streamVersion: 1, payload: { pipelineRunId: "pr" as never, - laneKey: "implement" as never, laneEntryToken: "tok" as never } }); - yield* pipeline.projectEvent({ ...base, type: "StepStarted", eventId: "c" as never, - streamVersion: 2, payload: { pipelineRunId: "pr" as never, stepRunId: "sr" as never, - stepKey: "code" as never, stepType: "agent" } }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { boardId: "b-1" as never, title: "Z" as never, laneKey: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr" as never, + laneKey: "implement" as never, + laneEntryToken: "tok" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr" as never, + stepRunId: "sr" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); const detail = yield* read.getTicketDetail("t-9" as never); assert.equal(detail?.ticket.title, "Z"); @@ -1403,8 +1608,11 @@ export interface TicketDetail { export interface WorkflowReadModelShape { readonly registerBoard: (board: { - readonly boardId: BoardId; readonly projectId: ProjectId; readonly name: string; - readonly workflowFilePath: string; readonly workflowVersionHash: string; + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; readonly maxConcurrentTickets: number; }) => Effect.Effect; readonly getBoard: (boardId: BoardId) => Effect.Effect; @@ -1416,10 +1624,9 @@ export interface WorkflowReadModelShape { ) => Effect.Effect; } -export class WorkflowReadModel extends Context.Service< - WorkflowReadModel, - WorkflowReadModelShape ->()("t3/workflow/Services/WorkflowReadModel") {} +export class WorkflowReadModel extends Context.Service()( + "t3/workflow/Services/WorkflowReadModel", +) {} ``` - [ ] **Step 4: Write the layer implementation** @@ -1515,6 +1722,7 @@ git commit -m "feat(workflow): add WorkflowReadModel query service" Bundle the milestone's layers so later milestones (and tests) compose one entry, and confirm the whole package typechecks. **Files:** + - Create: `apps/server/src/workflow/WorkflowFoundationLive.ts` - Test: `apps/server/src/workflow/WorkflowFoundationLive.test.ts` diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md index 6053ea1f3c1..a2b9876ab99 100644 --- a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md @@ -17,6 +17,7 @@ ## File Structure **Create:** + - `apps/server/src/persistence/Migrations/0YY_WorkflowTicketToken.ts` — add `current_lane_entry_token` to `projection_ticket`. - `apps/server/src/workflow/Services/WorkflowIds.ts` + `Layers/WorkflowIds.ts` — id/token generation seam. - `apps/server/src/workflow/Services/WorkflowEventCommitter.ts` + `Layers/WorkflowEventCommitter.ts` — append-then-project helper. @@ -28,6 +29,7 @@ - `apps/server/src/workflow/WorkflowEngineLive.ts` — aggregated M2 layer. **Modify:** + - `apps/server/src/persistence/Migrations.ts` — register the token migration. - `packages/contracts/src/workflow.ts` — add `StepOutcome` schema (Task 5). @@ -36,6 +38,7 @@ ## Task 1: Migration — current lane-entry token on tickets **Files:** + - Create: `apps/server/src/persistence/Migrations/0YY_WorkflowTicketToken.ts` - Modify: `apps/server/src/persistence/Migrations.ts` - Test: `apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts` @@ -133,6 +136,7 @@ git commit -m "feat(workflow): project current lane-entry token onto tickets" Inject id/token creation so the engine is deterministic in tests. The Live layer uses the repo's existing id utility; a Deterministic layer is provided for tests. **Files:** + - Create: `apps/server/src/workflow/Services/WorkflowIds.ts` - Create: `apps/server/src/workflow/Layers/WorkflowIds.ts` - Test: `apps/server/src/workflow/Layers/WorkflowIds.test.ts` @@ -171,7 +175,11 @@ Expected: FAIL — module not found. ```typescript // apps/server/src/workflow/Services/WorkflowIds.ts import type { - LaneEntryToken, PipelineRunId, StepRunId, TicketId, WorkflowEventId, + LaneEntryToken, + PipelineRunId, + StepRunId, + TicketId, + WorkflowEventId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -194,7 +202,11 @@ export class WorkflowIds extends Context.Service( ```typescript // apps/server/src/workflow/Layers/WorkflowIds.ts import { - LaneEntryToken, PipelineRunId, StepRunId, TicketId, WorkflowEventId, + LaneEntryToken, + PipelineRunId, + StepRunId, + TicketId, + WorkflowEventId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -259,6 +271,7 @@ git commit -m "feat(workflow): add WorkflowIds id/token seam" A single path the engine uses to persist an event and update projections, mirroring how orchestration appends then projects. (M3 will extend this to also push to subscribers; M2 keeps it append+project.) **Files:** + - Create: `apps/server/src/workflow/Services/WorkflowEventCommitter.ts` - Create: `apps/server/src/workflow/Layers/WorkflowEventCommitter.ts` - Test: `apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts` @@ -291,7 +304,9 @@ layer("WorkflowEventCommitter", (it) => { const committer = yield* WorkflowEventCommitter; const sql = yield* SqlClient.SqlClient; yield* committer.commit({ - type: "TicketCreated", eventId: "e1" as never, ticketId: "t-1" as never, + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never, payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, }); @@ -317,9 +332,7 @@ import type { WorkflowEventInput } from "./WorkflowEventStore.ts"; import type { WorkflowEventStoreError } from "./Errors.ts"; export interface WorkflowEventCommitterShape { - readonly commit: ( - event: WorkflowEventInput, - ) => Effect.Effect; + readonly commit: (event: WorkflowEventInput) => Effect.Effect; } export class WorkflowEventCommitter extends Context.Service< @@ -373,6 +386,7 @@ git commit -m "feat(workflow): add WorkflowEventCommitter (append+project)" Holds the validated `WorkflowDefinition` per board so the engine can resolve lanes and routing. Parsing/linting reuses Milestone 1. **Files:** + - Create: `apps/server/src/workflow/Services/BoardRegistry.ts` - Create: `apps/server/src/workflow/Layers/BoardRegistry.ts` - Test: `apps/server/src/workflow/Layers/BoardRegistry.test.ts` @@ -392,10 +406,20 @@ const def = { name: "wf", lanes: [ { key: "backlog", name: "Backlog", entry: "manual" }, - { key: "impl", name: "Impl", entry: "auto", - pipeline: [{ key: "code", type: "agent", - agent: { instance: "claude_main", model: "sonnet" }, instruction: "do it" }], - on: { success: "done" } }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, { key: "done", name: "Done", entry: "manual", terminal: true }, ], }; @@ -414,9 +438,12 @@ layer("BoardRegistry", (it) => { it.effect("rejects an invalid definition", () => Effect.gen(function* () { const reg = yield* BoardRegistry; - const result = yield* reg.register("b-2" as never, { - name: "bad", lanes: [{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }], - }).pipe(Effect.either); + const result = yield* reg + .register("b-2" as never, { + name: "bad", + lanes: [{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }], + }) + .pipe(Effect.either); assert.equal(result._tag, "Left"); }), ); @@ -436,21 +463,18 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -export class BoardRegistryError extends Schema.TaggedError()( +export class BoardRegistryError extends Schema.TaggedErrorClass()( "BoardRegistryError", { message: Schema.String }, ) {} export interface BoardRegistryShape { readonly register: ( - boardId: BoardId, def: unknown, - ) => Effect.Effect; - readonly getDefinition: ( boardId: BoardId, - ) => Effect.Effect; - readonly getLane: ( - boardId: BoardId, laneKey: LaneKey, - ) => Effect.Effect; + def: unknown, + ) => Effect.Effect; + readonly getDefinition: (boardId: BoardId) => Effect.Effect; + readonly getLane: (boardId: BoardId, laneKey: LaneKey) => Effect.Effect; } export class BoardRegistry extends Context.Service()( @@ -468,7 +492,9 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { - BoardRegistry, BoardRegistryError, type BoardRegistryShape, + BoardRegistry, + BoardRegistryError, + type BoardRegistryShape, } from "../Services/BoardRegistry.ts"; import { lintWorkflowDefinition } from "../workflowFile.ts"; @@ -478,8 +504,9 @@ const make = Effect.gen(function* () { const register: BoardRegistryShape["register"] = (boardId, raw) => Effect.gen(function* () { const def = yield* Schema.decodeUnknown(WorkflowDefinition)(raw).pipe( - Effect.mapError((cause) => - new BoardRegistryError({ message: `Invalid workflow: ${String(cause)}` })), + Effect.mapError( + (cause) => new BoardRegistryError({ message: `Invalid workflow: ${String(cause)}` }), + ), ); // In M2 the lint context permits all providers/files; M5 wires real checks. const errors = lintWorkflowDefinition(def, { @@ -487,8 +514,11 @@ const make = Effect.gen(function* () { instructionFileExists: () => true, }); if (errors.length > 0) { - return yield* Effect.fail(new BoardRegistryError({ - message: `Workflow lint failed: ${errors.map((e) => e.code).join(", ")}` })); + return yield* Effect.fail( + new BoardRegistryError({ + message: `Workflow lint failed: ${errors.map((e) => e.code).join(", ")}`, + }), + ); } yield* Ref.update(store, (m) => new Map(m).set(boardId, def)); return def; @@ -522,6 +552,7 @@ git commit -m "feat(workflow): add BoardRegistry for parsed workflow definitions The seam M3 fills with real provider execution. M2 provides a stub that returns a scripted outcome so the engine is testable. **Files:** + - Modify: `packages/contracts/src/workflow.ts` (add `StepOutcome`) - Create: `apps/server/src/workflow/Services/StepExecutor.ts` - Create: `apps/server/src/workflow/Layers/StubStepExecutor.ts` @@ -544,12 +575,17 @@ function layerFromStub() { Effect.gen(function* () { const exec = yield* StepExecutor; const outcome = yield* exec.execute({ - ticketId: "t-1" as never, boardId: "b-1" as never, - pipelineRunId: "pr-1" as never, stepRunId: "sr-1" as never, + ticketId: "t-1" as never, + boardId: "b-1" as never, + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, laneEntryToken: "tok-1" as never, - step: { key: "code" as never, type: "agent", + step: { + key: "code" as never, + type: "agent", agent: { instance: "claude_main" as never, model: "sonnet" as never }, - instruction: "x" }, + instruction: "x", + }, }); assert.equal(outcome._tag, "completed"); }), @@ -579,7 +615,13 @@ export type StepOutcome = typeof StepOutcome.Type; ```typescript // apps/server/src/workflow/Services/StepExecutor.ts import type { - BoardId, LaneEntryToken, PipelineRunId, StepOutcome, StepRunId, TicketId, WorkflowStep, + BoardId, + LaneEntryToken, + PipelineRunId, + StepOutcome, + StepRunId, + TicketId, + WorkflowStep, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -620,8 +662,7 @@ export interface StubScript { export const makeStubStepExecutor = (script: StubScript): Layer.Layer => Layer.succeed(StepExecutor, { - execute: (ctx) => - Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), + execute: (ctx) => Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), } satisfies StepExecutorShape); ``` @@ -639,6 +680,7 @@ git commit -m "feat(workflow): add StepExecutor port and stub + StepOutcome cont Parks approval steps until resolved. (M3 makes approvals durable; M2 is in-process.) **Files:** + - Create: `apps/server/src/workflow/Services/ApprovalGate.ts` - Create: `apps/server/src/workflow/Layers/ApprovalGate.ts` - Test: `apps/server/src/workflow/Layers/ApprovalGate.test.ts` @@ -649,6 +691,7 @@ Parks approval steps until resolved. (M3 makes approvals durable; M2 is in-proce // apps/server/src/workflow/Layers/ApprovalGate.test.ts import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import { ApprovalGate } from "../Services/ApprovalGate.ts"; import { ApprovalGateLive } from "./ApprovalGate.ts"; @@ -658,10 +701,10 @@ layer("ApprovalGate", (it) => { it.effect("await resolves once resolve is called", () => Effect.gen(function* () { const gate = yield* ApprovalGate; - const fiber = yield* Effect.fork(gate.await("sr-1" as never)); - yield* Effect.yieldNow(); + const fiber = yield* Effect.forkChild(gate.await("sr-1" as never)); + yield* Effect.yieldNow; yield* gate.resolve("sr-1" as never, true); - const approved = yield* fiber.await.pipe(Effect.flatMap((e) => e)); + const approved = yield* Fiber.join(fiber); assert.equal(approved, true); }), ); @@ -743,6 +786,7 @@ git commit -m "feat(workflow): add in-memory ApprovalGate" The state machine. Public API: `createTicket`, `moveTicket` (manual drag), `runLane` (manual entry), `resolveApproval`. Internally it runs a lane's pipeline and routes on completion with token guarding. **Files:** + - Create: `apps/server/src/workflow/Services/WorkflowEngine.ts` - Create: `apps/server/src/workflow/Layers/WorkflowEngine.ts` @@ -757,20 +801,24 @@ import type { WorkflowEventStoreError } from "./Errors.ts"; export interface WorkflowEngineShape { readonly createTicket: (input: { - readonly boardId: BoardId; readonly title: string; - readonly description?: string; readonly initialLane: LaneKey; + readonly boardId: BoardId; + readonly title: string; + readonly description?: string; + readonly initialLane: LaneKey; }) => Effect.Effect; /** Manual drag: move a ticket to a lane, supersede any in-flight run, trigger entry. */ readonly moveTicket: ( - ticketId: TicketId, toLane: LaneKey, + ticketId: TicketId, + toLane: LaneKey, ) => Effect.Effect; /** Manually start a lane's pipeline (for `entry: manual` lanes with a pipeline). */ readonly runLane: (ticketId: TicketId) => Effect.Effect; readonly resolveApproval: ( - stepRunId: StepRunId, approved: boolean, + stepRunId: StepRunId, + approved: boolean, ) => Effect.Effect; } @@ -806,31 +854,39 @@ const make = Effect.gen(function* () { // Read the ticket's current lane-entry token to guard stale routing. const currentToken = (ticketId: TicketId) => - read.getTicketDetail(ticketId).pipe( - Effect.map((d) => (d?.ticket as { current_lane_entry_token?: string } | undefined) - ?.current_lane_entry_token ?? null), - ); + read.getTicketDetail(ticketId).pipe(Effect.map((d) => d?.ticket.currentLaneEntryToken ?? null)); // Run a lane's pipeline for a ticket, bound to `laneEntryToken`. const runPipeline = ( - ticketId: TicketId, boardId: string, lane: WorkflowLane, laneEntryToken: string, + ticketId: TicketId, + boardId: string, + lane: WorkflowLane, + laneEntryToken: string, ): Effect.Effect => Effect.gen(function* () { const steps = lane.pipeline ?? []; if (steps.length === 0) return; // nothing to run; lane just holds the ticket const pipelineRunId = yield* ids.pipelineRunId(); - yield* commit({ type: "PipelineStarted", ticketId, - payload: { pipelineRunId, laneKey: lane.key, laneEntryToken } }); + yield* commit({ + type: "PipelineStarted", + ticketId, + payload: { pipelineRunId, laneKey: lane.key, laneEntryToken }, + }); let result: "success" | "failure" | "blocked" = "success"; for (const step of steps) { const outcome = yield* runStep(ticketId, boardId, pipelineRunId, step, laneEntryToken); - if (outcome === "failed") { result = "failure"; break; } - if (outcome === "blocked") { result = "blocked"; break; } + if (outcome === "failed") { + result = "failure"; + break; + } + if (outcome === "blocked") { + result = "blocked"; + break; + } } - yield* commit({ type: "PipelineCompleted", ticketId, - payload: { pipelineRunId, result } }); + yield* commit({ type: "PipelineCompleted", ticketId, payload: { pipelineRunId, result } }); // Token guard: only route if the ticket is still on this lane-entry token. const token = yield* currentToken(ticketId); @@ -839,22 +895,34 @@ const make = Effect.gen(function* () { }).pipe(Effect.catchAll(() => Effect.void)); // pipeline errors never crash the engine const runStep = ( - ticketId: TicketId, boardId: string, pipelineRunId: string, step: WorkflowStep, + ticketId: TicketId, + boardId: string, + pipelineRunId: string, + step: WorkflowStep, laneEntryToken: string, ): Effect.Effect<"completed" | "failed" | "blocked", never> => Effect.gen(function* () { const stepRunId = yield* ids.stepRunId(); - yield* commit({ type: "StepStarted", ticketId, - payload: { pipelineRunId, stepRunId, stepKey: step.key, stepType: step.type } }); + yield* commit({ + type: "StepStarted", + ticketId, + payload: { pipelineRunId, stepRunId, stepKey: step.key, stepType: step.type }, + }); if (step.type === "approval") { - yield* commit({ type: "StepAwaitingUser", ticketId, - payload: { stepRunId, waitingReason: step.prompt ?? "Approval required" } }); + yield* commit({ + type: "StepAwaitingUser", + ticketId, + payload: { stepRunId, waitingReason: step.prompt ?? "Approval required" }, + }); const approved = yield* approvals.await(stepRunId); yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); if (!approved) { - yield* commit({ type: "StepFailed", ticketId, - payload: { stepRunId, error: "rejected" } }); + yield* commit({ + type: "StepFailed", + ticketId, + payload: { stepRunId, error: "rejected" }, + }); return "failed"; } yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); @@ -863,11 +931,19 @@ const make = Effect.gen(function* () { // agent step → delegate to the executor port (real impl is M3) const outcome = yield* executor.execute({ - ticketId, boardId: boardId as never, pipelineRunId: pipelineRunId as never, - stepRunId, laneEntryToken: laneEntryToken as never, step }); + ticketId, + boardId: boardId as never, + pipelineRunId: pipelineRunId as never, + stepRunId, + laneEntryToken: laneEntryToken as never, + step, + }); if (outcome._tag === "awaiting_user") { - yield* commit({ type: "StepAwaitingUser", ticketId, - payload: { stepRunId, waitingReason: outcome.waitingReason } }); + yield* commit({ + type: "StepAwaitingUser", + ticketId, + payload: { stepRunId, waitingReason: outcome.waitingReason }, + }); const approved = yield* approvals.await(stepRunId); yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); // M2: treat any resolution as completion; M3 implements re-dispatch semantics. @@ -875,8 +951,11 @@ const make = Effect.gen(function* () { return approved ? "completed" : "failed"; } if (outcome._tag === "failed") { - yield* commit({ type: "StepFailed", ticketId, - payload: { stepRunId, error: outcome.error } }); + yield* commit({ + type: "StepFailed", + ticketId, + payload: { stepRunId, error: outcome.error }, + }); return "failed"; } yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); @@ -884,15 +963,20 @@ const make = Effect.gen(function* () { }); const route = ( - ticketId: TicketId, boardId: string, lane: WorkflowLane, + ticketId: TicketId, + boardId: string, + lane: WorkflowLane, result: "success" | "failure" | "blocked", ): Effect.Effect => Effect.gen(function* () { const target = lane.on?.[result]; if (!target) { if (result !== "success") { - yield* commit({ type: "TicketBlocked", ticketId, - payload: { reason: `pipeline ${result} with no route` } }); + yield* commit({ + type: "TicketBlocked", + ticketId, + payload: { reason: `pipeline ${result} with no route` }, + }); } return; } @@ -901,23 +985,30 @@ const make = Effect.gen(function* () { // Move a ticket to a lane (new token), then trigger the lane's entry behavior. const moveToLane = ( - ticketId: TicketId, boardId: string, toLane: LaneKey, + ticketId: TicketId, + boardId: string, + toLane: LaneKey, reason: "manual" | "routed" | "initial", ): Effect.Effect => Effect.gen(function* () { const token = yield* ids.token(); - yield* commit({ type: "TicketMovedToLane", ticketId, - payload: { toLane, laneEntryToken: token, reason } }); + yield* commit({ + type: "TicketMovedToLane", + ticketId, + payload: { toLane, laneEntryToken: token, reason }, + }); const lane = yield* registry.getLane(boardId as never, toLane); if (lane && lane.entry === "auto") { // fork so chained auto lanes don't deepen the stack unbounded - yield* Effect.forkDaemon(runPipeline(ticketId, boardId, lane, token)); + yield* Effect.forkDetach(runPipeline(ticketId, boardId, lane, token)); } }); // commit wrapper that injects eventId + occurredAt function commit(e: { - type: string; ticketId: TicketId; payload: unknown; + type: string; + ticketId: TicketId; + payload: unknown; }): Effect.Effect { return Effect.gen(function* () { const eventId = yield* ids.eventId(); @@ -929,10 +1020,18 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const ticketId = yield* ids.ticketId(); const eventId = yield* ids.eventId(); - yield* committer.commit({ type: "TicketCreated", eventId, ticketId, + yield* committer.commit({ + type: "TicketCreated", + eventId, + ticketId, occurredAt: nowIso() as never, - payload: { boardId: input.boardId, title: input.title as never, - laneKey: input.initialLane, description: input.description } as never }); + payload: { + boardId: input.boardId, + title: input.title as never, + laneKey: input.initialLane, + description: input.description, + } as never, + }); yield* moveToLane(ticketId, input.boardId, input.initialLane, "initial"); return ticketId; }); @@ -954,7 +1053,7 @@ const make = Effect.gen(function* () { const lane = yield* registry.getLane(boardId as never, laneKey); const token = yield* currentToken(ticketId); if (lane && token) { - yield* Effect.forkDaemon(runPipeline(ticketId, boardId, lane, token)); + yield* Effect.forkDetach(runPipeline(ticketId, boardId, lane, token)); } }); @@ -967,7 +1066,7 @@ const make = Effect.gen(function* () { export const WorkflowEngineLayer = Layer.effect(WorkflowEngine, make); ``` -> Concurrency note: this task omits the `maxConcurrentTickets` gate — Task 9 adds it. Routing emits `TicketMovedToLane` with `reason: "routed"` (the unused `TicketRouted` event from M1 is reserved/deprecated). `currentToken` reads the projection column added in Task 1; adjust the property access to the exact casing `WorkflowReadModel` returns (extend `TicketRow` to expose `currentLaneEntryToken` — see Task 8). +> Concurrency note: this task omits the `maxConcurrentTickets` gate — Task 9 adds it. Routing emits `TicketMovedToLane` with `reason: "routed"` (the unused `TicketRouted` event from M1 is reserved/deprecated). `currentToken` reads the projection column added in Task 1 through the read-model field `currentLaneEntryToken` (extend `TicketRow` to expose it — see Task 8). - [ ] **Step 3: Extend WorkflowReadModel TicketRow** to expose the token (so `currentToken` is typed): @@ -985,6 +1084,7 @@ git commit -m "feat(workflow): add WorkflowEngine state machine (create/run/rout ## Task 8: Integration test — 3-lane flow, approval pause/resume, drag supersede **Files:** + - Create: `apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts` - [ ] **Step 1: Write the test** @@ -1011,10 +1111,20 @@ const def = { name: "wf", lanes: [ { key: "backlog", name: "Backlog", entry: "manual" }, - { key: "impl", name: "Impl", entry: "auto", - pipeline: [{ key: "code", type: "agent", - agent: { instance: "claude_main", model: "sonnet" }, instruction: "do it" }], - on: { success: "done", failure: "needs" } }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs" }, + }, { key: "needs", name: "Needs", entry: "manual" }, { key: "done", name: "Done", entry: "manual", terminal: true }, ], @@ -1045,21 +1155,30 @@ successLayer("WorkflowEngine integration (success path)", (it) => { const read = yield* WorkflowReadModel; const ticketId = yield* engine.createTicket({ - boardId: "b-1" as never, title: "Export", initialLane: "impl" as never }); + boardId: "b-1" as never, + title: "Export", + initialLane: "impl" as never, + }); // allow forked auto-pipeline + chained routing to settle yield* Effect.sleep("50 millis"); const detail = yield* read.getTicketDetail(ticketId); assert.equal(detail?.ticket.currentLaneKey, "done"); - assert.equal(detail?.steps.some((s) => s.status === "completed"), true); + assert.equal( + detail?.steps.some((s) => s.status === "completed"), + true, + ); }), ); }); const failLayer = it.layer( - baseLayer(makeStubStepExecutor({ - default: { _tag: "failed", error: "boom" } }) as never), + baseLayer( + makeStubStepExecutor({ + default: { _tag: "failed", error: "boom" }, + }) as never, + ), ); failLayer("WorkflowEngine integration (failure path)", (it) => { @@ -1070,7 +1189,10 @@ failLayer("WorkflowEngine integration (failure path)", (it) => { const engine = yield* WorkflowEngine; const read = yield* WorkflowReadModel; const ticketId = yield* engine.createTicket({ - boardId: "b-1" as never, title: "Y", initialLane: "impl" as never }); + boardId: "b-1" as never, + title: "Y", + initialLane: "impl" as never, + }); yield* Effect.sleep("50 millis"); const detail = yield* read.getTicketDetail(ticketId); assert.equal(detail?.ticket.currentLaneKey, "needs"); @@ -1091,9 +1213,13 @@ Expected: PASS (success routes to `done`, failure routes to `needs`). If timing const approvalDef = { name: "wf", lanes: [ - { key: "review", name: "Review", entry: "auto", + { + key: "review", + name: "Review", + entry: "auto", pipeline: [{ key: "ok", type: "approval", prompt: "Approve?" }], - on: { success: "done", failure: "needs" } }, + on: { success: "done", failure: "needs" }, + }, { key: "needs", name: "Needs", entry: "manual" }, { key: "done", name: "Done", entry: "manual", terminal: true }, ], @@ -1107,7 +1233,10 @@ successLayer("WorkflowEngine approval gate", (it) => { const engine = yield* WorkflowEngine; const read = yield* WorkflowReadModel; const ticketId = yield* engine.createTicket({ - boardId: "b-2" as never, title: "Approve me", initialLane: "review" as never }); + boardId: "b-2" as never, + title: "Approve me", + initialLane: "review" as never, + }); yield* Effect.sleep("30 millis"); let detail = yield* read.getTicketDetail(ticketId); @@ -1139,6 +1268,7 @@ git commit -m "test(workflow): integration tests for pipeline routing and approv Cap concurrently-running tickets per board with a semaphore keyed by board. **Files:** + - Modify: `apps/server/src/workflow/Layers/WorkflowEngine.ts` - Test: `apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts` @@ -1163,30 +1293,35 @@ In `make` (WorkflowEngine layer), build a per-board semaphore map and acquire a ```typescript import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; // inside make(): -const boardSemaphores = yield* Ref.make>(new Map()); - -const semaphoreFor = (boardId: string, permits: number) => - Effect.gen(function* () { - const map = yield* Ref.get(boardSemaphores); - const existing = map.get(boardId); - if (existing) return existing; - const sem = yield* Effect.makeSemaphore(permits); - yield* Ref.update(boardSemaphores, (m) => new Map(m).set(boardId, sem)); - return sem; - }); +const makeSemaphoreRegistry = Effect.gen(function* () { + const boardSemaphores = yield* Ref.make>(new Map()); + + return (boardId: string, permits: number) => + Ref.modifyEffect(boardSemaphores, (map) => { + const existing = map.get(boardId); + if (existing) return Effect.succeed([existing, map] as const); + return Semaphore.make(permits).pipe( + Effect.map((sem) => [sem, new Map(map).set(boardId, sem)] as const), + ); + }); +}); ``` Wrap the body of `runPipeline` so it acquires one permit for its duration: ```typescript -const def = yield* registry.getDefinition(boardId as never); -const permits = def?.settings?.maxConcurrentTickets ?? 3; -const sem = yield* semaphoreFor(boardId, permits); -yield* sem.withPermits(1)(/* existing pipeline body Effect */); +const gatedPipelineBody = Effect.gen(function* () { + const def = yield* registry.getDefinition(boardId as never); + const permits = def?.settings?.maxConcurrentTickets ?? 3; + const sem = yield* semaphoreFor(boardId, permits); + yield* sem.withPermits(1)(/* existing pipeline body Effect */); +}); ``` -> Verify the exact API (`Effect.makeSemaphore(n)` returns a `Semaphore` with `withPermits`) against the installed Effect beta; mirror any existing semaphore use found in the codebase. Add `import * as Ref from "effect/Ref"` if not already imported. +> Use `effect/Semaphore` in the installed Effect beta (`Semaphore.make(permits)` and `sem.withPermits(1)(effect)`). Do not build the semaphore with a separate `Ref.get` then `Ref.update`: two fibers can race and create two semaphores for the same board. The `Ref.modifyEffect` form above is the atomic get-or-create path. Keep `PipelineStarted` inside the acquired permit so queued tickets remain idle until they actually start running. - [ ] **Step 4: Run + commit** @@ -1202,6 +1337,7 @@ git commit -m "feat(workflow): per-board concurrency gate for running tickets" ## Task 10: Aggregate WorkflowEngineLive + typecheck **Files:** + - Create: `apps/server/src/workflow/WorkflowEngineLive.ts` - Test: reuse Task 8 integration layer wiring (no new test needed beyond a typecheck). @@ -1255,8 +1391,8 @@ git commit -m "feat(workflow): aggregate WorkflowEngine core layer" ## Notes for the implementer -- **Forked pipelines:** `runPipeline` is forked (`forkDaemon`) so chained `auto` lanes don't grow the stack and so `createTicket`/`moveTicket` return promptly. Tests settle with a short sleep or a poll loop — prefer polling the read model for determinism. +- **Forked pipelines:** `runPipeline` is forked with `Effect.forkDetach` so chained `auto` lanes don't grow the stack and so `createTicket`/`moveTicket` return promptly. Tests settle with a short sleep or a poll loop — prefer polling the read model for determinism. - **Token guard is the supersede mechanism:** a completing pipeline checks the ticket's `current_lane_entry_token`; if a manual `moveTicket` changed it, the stale pipeline records `PipelineCompleted` but does **not** route. (M3 adds active interruption of the in-flight provider turn; M2 just refuses to route.) - **In-process only:** M2 has no crash recovery; killing the server loses in-flight pipeline fibers. That's intentional — M3's durable outbox/lease makes runs recoverable. - **`as never` casts** in tests are fixture shortcuts against branded IDs; production call sites construct IDs via `WorkflowIds`. -- **Effect API drift:** confirm `Effect.makeSemaphore`, `Effect.forkDaemon`, `Deferred`, and `Ref` import paths against the installed beta; mirror existing usages where they differ. +- **Effect API drift:** this repo uses `effect@4.0.0-beta.78`; use `Schema.TaggedErrorClass`, `Effect.forkDetach`/`Effect.forkChild`, `yield* Effect.yieldNow`, `Fiber.join(fiber)`, and `effect/Semaphore`. Do not use nonexistent `Schema.TaggedError`, `Effect.fork`, `Effect.forkDaemon`, `Effect.makeSemaphore`, or `fiber.await.pipe(...)` in copied snippets. diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md index dbc157ab9b9..5ccdec611df 100644 --- a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md @@ -11,11 +11,12 @@ **Spec:** `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md` (§7.1 lease, §7.2/7.2.1 setup, §7.3 waits, §7.8 dispatch, §7.9 recovery). **Reference signatures (verified from the codebase — confirm exact shapes when implementing):** -- `ProviderService.startSession(threadId, ProviderSessionStartInput)` and `sendTurn(ProviderSendTurnInput)`, `interruptTurn(...)`, `respondToUserInput(...)`, `respondToRequest(...)` — `apps/server/src/provider/Services/ProviderService.ts`. -- Terminal turn state: `projection_turns.state ∈ {pending,running,interrupted,completed,error}` + `completedAt`; read via `ProjectionTurnRepository.getByTurnId(...)`. -- `ProjectSetupScriptRunner.runForThread(input) → { status: "started", scriptId, terminalId }`; await completion via `TerminalManager.subscribe` watching `TerminalExitedEvent { exitCode }`. + +- `ProviderService.startSession(threadId, ProviderSessionStartInput)` and `sendTurn(ProviderSendTurnInput)`, `interruptTurn(...)`, `respondToUserInput(...)`, `respondToRequest(...)` — `apps/server/src/provider/Services/ProviderService.ts`. `ProviderSendTurnInput` is `{ threadId, input?, attachments?, modelSelection?, interactionMode? }`; it does not accept `commandId`, `messageId`, or a `message` object. +- Terminal turn state: `projection_turns.state ∈ {pending,running,interrupted,completed,error}` + `completedAt`; read via `ProjectionTurnRepository.listByThreadId(...)` / `getByTurnId(...)`. There is no `getLatestByThreadId` method in the current repository interface. +- `ProjectSetupScriptRunner.runForThread(input) → { status: "no-script" } | { status: "started", scriptId, scriptName, terminalId, cwd }`; await completion via `TerminalManager.subscribe(listener)` watching `TerminalExitedEvent { type: "exited", terminalId, exitCode }` and filtering by terminal id. - `GitWorkflowService.createWorktree(input) → VcsCreateWorktreeResult { worktree: { path, refName } }`. -- `Effect.makeSemaphore` / `Semaphore.make(1).withPermits(1)(effect)`, `Deferred`, `Ref` — confirmed idioms. +- Effect 4 beta idioms: `effect/Semaphore` (`Semaphore.make`, `sem.withPermits(1)(effect)`), `Effect.forkDetach`, `Effect.forkChild`, `yield* Effect.yieldNow`, `Schema.TaggedErrorClass`, `Deferred`, `Ref`. **Out of scope:** ticket checkpoints/diff (M4), RPC + UI (M5). @@ -24,11 +25,13 @@ ## File Structure **Create (migrations):** + - `apps/server/src/persistence/Migrations/0AA_WorkflowLease.ts` — `worktree_lease`. - `apps/server/src/persistence/Migrations/0BB_WorkflowDispatchOutbox.ts` — `workflow_dispatch_outbox`. - `apps/server/src/persistence/Migrations/0CC_WorkflowSetupRun.ts` — `workflow_setup_run`. **Create (services + layers, each with a colocated test):** + - `workflow/Services/WorktreeLeaseService.ts` + `Layers/WorktreeLeaseService.ts` - `workflow/Services/SetupRunService.ts` + `Layers/SetupRunService.ts` - `workflow/Services/TurnStateReader.ts` + `Layers/TurnStateReader.ts` @@ -39,6 +42,7 @@ - `workflow/WorkflowRuntimeLive.ts` — aggregates M2 engine + RealStepExecutor + recovery. **Modify:** + - `apps/server/src/persistence/Migrations.ts` — register the three migrations. - `packages/contracts/src/workflow.ts` — add lease/dispatch/setup row + status schemas and the new workflow events (`ProviderDispatchRequested`, `ProviderDispatchConfirmed`, `WorktreeLeaseAcquired/Released`, `SetupStarted/Completed/Failed`). @@ -47,6 +51,7 @@ ## Task 1: Migrations — lease, dispatch outbox, setup run **Files:** + - Create the three migration files; modify `Migrations.ts`. - Test: `apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts` @@ -112,14 +117,14 @@ export default Effect.gen(function* () { ticket_id TEXT NOT NULL, step_run_id TEXT NOT NULL, thread_id TEXT NOT NULL, - command_id TEXT NOT NULL, - message_id TEXT NOT NULL, + turn_id TEXT, provider_instance TEXT NOT NULL, model TEXT NOT NULL, instruction TEXT NOT NULL, worktree_path TEXT NOT NULL, - status TEXT NOT NULL, -- 'pending' | 'confirmed' + status TEXT NOT NULL, -- 'pending' | 'started' | 'confirmed' created_at TEXT NOT NULL, + started_at TEXT, confirmed_at TEXT )`; yield* sql` @@ -161,6 +166,7 @@ git commit -m "feat(workflow): add lease, dispatch-outbox, and setup-run tables" ## Task 2: WorktreeLeaseService (fenced single-writer) **Files:** + - Create `workflow/Services/WorktreeLeaseService.ts` + `Layers/WorktreeLeaseService.ts` + test. - [ ] **Step 1: Write the failing test** @@ -177,7 +183,9 @@ import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; const layer = it.layer( WorktreeLeaseServiceLive.pipe( - Layer.provideMerge(MigrationsLive), Layer.provideMerge(SqlitePersistenceMemory)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), ); layer("WorktreeLeaseService", (it) => { @@ -214,22 +222,29 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type { WorkflowEventStoreError } from "./Errors.ts"; -export interface Lease { readonly fenceToken: number; } +export interface Lease { + readonly fenceToken: number; +} export interface WorktreeLeaseServiceShape { readonly acquire: ( - worktreeRef: string, ownerKind: "step" | "user", ownerId: string, + worktreeRef: string, + ownerKind: "step" | "user", + ownerId: string, ) => Effect.Effect; readonly release: ( - worktreeRef: string, fenceToken: number, + worktreeRef: string, + fenceToken: number, ) => Effect.Effect; readonly isValid: ( - worktreeRef: string, fenceToken: number, + worktreeRef: string, + fenceToken: number, ) => Effect.Effect; } export class WorktreeLeaseService extends Context.Service< - WorktreeLeaseService, WorktreeLeaseServiceShape + WorktreeLeaseService, + WorktreeLeaseServiceShape >()("t3/workflow/Services/WorktreeLeaseService") {} ``` @@ -241,13 +256,17 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { - WorktreeLeaseService, type Lease, type WorktreeLeaseServiceShape, + WorktreeLeaseService, + type Lease, + type WorktreeLeaseServiceShape, } from "../Services/WorktreeLeaseService.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; const LEASE_TTL_MS = 30 * 60 * 1000; const wrap =
(e: Effect.Effect) => - e.pipe(Effect.mapError((cause) => new WorkflowEventStoreError({ message: "lease op failed", cause }))); + e.pipe( + Effect.mapError((cause) => new WorkflowEventStoreError({ message: "lease op failed", cause })), + ); const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -273,13 +292,15 @@ const make = Effect.gen(function* () { const release: WorktreeLeaseServiceShape["release"] = (worktreeRef, fenceToken) => wrap(sql` - DELETE FROM worktree_lease WHERE worktree_ref = ${worktreeRef} AND fence_token = ${fenceToken}`) - .pipe(Effect.asVoid); + DELETE FROM worktree_lease WHERE worktree_ref = ${worktreeRef} AND fence_token = ${fenceToken}`).pipe( + Effect.asVoid, + ); const isValid: WorktreeLeaseServiceShape["isValid"] = (worktreeRef, fenceToken) => wrap(sql<{ readonly fenceToken: number }>` - SELECT fence_token AS "fenceToken" FROM worktree_lease WHERE worktree_ref = ${worktreeRef}`) - .pipe(Effect.map((rows) => rows[0]?.fenceToken === fenceToken)); + SELECT fence_token AS "fenceToken" FROM worktree_lease WHERE worktree_ref = ${worktreeRef}`).pipe( + Effect.map((rows) => rows[0]?.fenceToken === fenceToken), + ); return { acquire, release, isValid } satisfies WorktreeLeaseServiceShape; }); @@ -287,7 +308,7 @@ const make = Effect.gen(function* () { export const WorktreeLeaseServiceLive = Layer.effect(WorktreeLeaseService, make); ``` -> Note: acquiring overwrites any prior lease (it always bumps the fence). In v1 the engine never double-acquires a live lease (steps are sequential per ticket); the fence's job is to invalidate *superseded* writers, which `isValid` enforces. +> Note: acquiring overwrites any prior lease (it always bumps the fence). In v1 the engine never double-acquires a live lease (steps are sequential per ticket); the fence's job is to invalidate _superseded_ writers, which `isValid` enforces. - [ ] **Step 5: Run → PASS. Commit.** @@ -303,6 +324,7 @@ git commit -m "feat(workflow): add fenced WorktreeLeaseService" Runs project setup scripts in the ticket worktree and resolves only when the setup terminal exits. **Files:** + - Create `workflow/Services/SetupRunService.ts` + `Layers/SetupRunService.ts` + test. - [ ] **Step 1: Write the failing test** (with stubbed runner + terminal) @@ -335,7 +357,8 @@ const mk = (exitCode: number) => SetupRunServiceLive.pipe( Layer.provideMerge(stubTerminal(exitCode)), Layer.provideMerge(MigrationsLive), - Layer.provideMerge(SqlitePersistenceMemory)), + Layer.provideMerge(SqlitePersistenceMemory), + ), ); mk(0)("SetupRunService (success)", (it) => { @@ -377,27 +400,37 @@ import type { WorkflowEventStoreError } from "./Errors.ts"; /** Thin port over ProjectSetupScriptRunner + TerminalManager so the gate is testable. */ export interface SetupTerminalPortShape { readonly launch: (input: { + readonly threadId?: string; + readonly projectId?: string; + readonly projectCwd?: string; readonly worktreePath: string; - }) => Effect.Effect<{ readonly terminalId: string }, WorkflowEventStoreError>; + readonly preferredTerminalId?: string; + }) => Effect.Effect<{ readonly terminalId: string | null }, WorkflowEventStoreError>; readonly awaitExit: (input: { - readonly terminalId: string; readonly timeoutMs?: number; + readonly terminalId: string | null; + readonly timeoutMs?: number; }) => Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError>; } -export class SetupTerminalPort extends Context.Service< - SetupTerminalPort, SetupTerminalPortShape ->()("t3/workflow/Services/SetupTerminalPort") {} +export class SetupTerminalPort extends Context.Service()( + "t3/workflow/Services/SetupTerminalPort", +) {} export type SetupStatus = "completed" | "failed" | "timed_out"; export interface SetupRunServiceShape { readonly runSetup: ( - ticketId: TicketId, worktreeRef: string, worktreePath: string, setupRunId: SetupRunId, - ) => Effect.Effect<{ readonly status: SetupStatus; readonly exitCode: number | null }, - WorkflowEventStoreError>; + ticketId: TicketId, + worktreeRef: string, + worktreePath: string, + setupRunId: SetupRunId, + ) => Effect.Effect< + { readonly status: SetupStatus; readonly exitCode: number | null }, + WorkflowEventStoreError + >; } -export class SetupRunService extends Context.Service< - SetupRunService, SetupRunServiceShape ->()("t3/workflow/Services/SetupRunService") {} +export class SetupRunService extends Context.Service()( + "t3/workflow/Services/SetupRunService", +) {} ``` Add `SetupRunId` to `packages/contracts/src/workflow.ts` (`makeId("SetupRunId")`). @@ -410,35 +443,48 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { - SetupRunService, SetupTerminalPort, type SetupRunServiceShape, + SetupRunService, + SetupTerminalPort, + type SetupRunServiceShape, } from "../Services/SetupRunService.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; const SETUP_TIMEOUT_MS = 10 * 60 * 1000; const wrap = (e: Effect.Effect) => - e.pipe(Effect.mapError((c) => new WorkflowEventStoreError({ message: "setup op failed", cause: c }))); + e.pipe( + Effect.mapError((c) => new WorkflowEventStoreError({ message: "setup op failed", cause: c })), + ); const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const terminal = yield* SetupTerminalPort; - const runSetup: SetupRunServiceShape["runSetup"] = (ticketId, worktreeRef, worktreePath, setupRunId) => + const runSetup: SetupRunServiceShape["runSetup"] = ( + ticketId, + worktreeRef, + worktreePath, + setupRunId, + ) => Effect.gen(function* () { const now = new Date().toISOString(); yield* wrap(sql` - INSERT INTO workflow_setup_run - (setup_run_id, ticket_id, worktree_ref, status, started_at) - VALUES (${setupRunId}, ${ticketId}, ${worktreeRef}, 'running', ${now}) + INSERT INTO workflow_setup_run + (setup_run_id, ticket_id, worktree_ref, status, started_at) + VALUES (${setupRunId}, ${ticketId}, ${worktreeRef}, 'running', ${now}) ON CONFLICT(ticket_id) DO UPDATE SET setup_run_id = excluded.setup_run_id, status = 'running', started_at = excluded.started_at, finished_at = NULL, exit_code = NULL`); const { terminalId } = yield* terminal.launch({ worktreePath }); - const exit = yield* terminal.awaitExit({ terminalId, timeoutMs: SETUP_TIMEOUT_MS }).pipe( - Effect.catchAll(() => Effect.succeed({ exitCode: -1 })), // timeout/crash → treat as failure below - ); - - const status = exit.exitCode === 0 ? "completed" : exit.exitCode === -1 ? "timed_out" : "failed"; + const exit = + terminalId === null + ? { exitCode: 0 } + : yield* terminal.awaitExit({ terminalId, timeoutMs: SETUP_TIMEOUT_MS }).pipe( + Effect.catchAll(() => Effect.succeed({ exitCode: -1 })), // timeout/crash → treat as failure below + ); + + const status = + exit.exitCode === 0 ? "completed" : exit.exitCode === -1 ? "timed_out" : "failed"; yield* wrap(sql` UPDATE workflow_setup_run SET status = ${status}, exit_code = ${exit.exitCode}, finished_at = ${new Date().toISOString()} WHERE ticket_id = ${ticketId}`); @@ -451,6 +497,8 @@ const make = Effect.gen(function* () { export const SetupRunServiceLive = Layer.effect(SetupRunService, make); ``` +Before inserting/updating to `running`, read the existing row for `ticket_id`. If it is `completed`, return `{ status: "completed", exitCode }` without launching a new terminal. If it is `running`, either wait on the existing terminal id if you persist it or return a running/in-progress result and let the caller poll; do not blindly rerun setup on every step. `status: "no-script"` from `ProjectSetupScriptRunner.runForThread` should be persisted as completed with `exit_code = 0`. + The production `SetupTerminalPort` layer wires the real services: ```typescript @@ -465,24 +513,35 @@ export const SetupTerminalPortLive = Layer.effect( const terminals = yield* TerminalManager; return { launch: (input) => - // confirm the real runner signature; it returns { terminalId } - runner.runForThread(/* { worktreePath, ... } — confirm input shape */ input as never).pipe( - Effect.map((r) => ({ terminalId: (r as { terminalId: string }).terminalId })), - Effect.mapError((cause) => new WorkflowEventStoreError({ message: "setup launch failed", cause })), - ), + runner + .runForThread({ + threadId: input.threadId ?? `workflow-setup:${input.worktreePath}`, + projectId: input.projectId, + projectCwd: input.projectCwd, + worktreePath: input.worktreePath, + }) + .pipe( + Effect.flatMap((r) => + r.status === "no-script" + ? Effect.succeed({ terminalId: null }) + : Effect.succeed({ terminalId: r.terminalId }), + ), + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "setup launch failed", cause }), + ), + ), awaitExit: (input) => - // subscribe to terminal events, resolve on TerminalExitedEvent { exitCode } - terminals.subscribe(/* terminalId */ input.terminalId as never).pipe( - // pseudo: take(first exited), map exitCode — implement against the real stream API - Effect.map(() => ({ exitCode: 0 })), - Effect.mapError((cause) => new WorkflowEventStoreError({ message: "setup await failed", cause })), - ), + // TerminalManager.subscribe takes only a listener and returns unsubscribe. + // Use a Deferred, complete it when event.type === "exited" and event.terminalId matches, + // then unsubscribe in an ensuring/finalizer path. If terminalId is null (`no-script`), + // return exitCode 0 immediately. + awaitTerminalExit(terminals, input), }; }), ); ``` -> Implementer: the production `SetupTerminalPortLive` is the one place you bridge to real services. Confirm `ProjectSetupScriptRunner.runForThread` input/return and the `TerminalManager` exit-event stream (`TerminalExitedEvent { exitCode }`), and implement `awaitExit` to take the first exited event with a timeout. The `SetupRunService` logic above is fully testable via the stub port. +> Implementer: the production `SetupTerminalPortLive` is the one place you bridge to real services. Use the real `ProjectSetupScriptRunnerInput` fields (`threadId`, optional `projectId`/`projectCwd`, `worktreePath`, optional `preferredTerminalId`) and the callback-only `TerminalManager.subscribe(listener)` API. Persist enough setup-run metadata to avoid rerunning setup once it has completed. - [ ] **Step 5: Run → PASS. Commit.** @@ -498,6 +557,7 @@ git commit -m "feat(workflow): add durable SetupRunService gated on terminal exi Reads whether a thread's turn reached terminal state, from `projection_turns` — never a live stream. **Files:** + - Create `workflow/Services/TurnStateReader.ts` + `Layers/TurnStateReader.ts` + test. - [ ] **Step 1: Write the failing test** (stub the projection-turn port) @@ -512,9 +572,11 @@ import * as Layer from "effect/Layer"; import { TurnStateReader, TurnProjectionPort } from "../Services/TurnStateReader.ts"; import { TurnStateReaderLive } from "./TurnStateReader.ts"; -const stub = (state: string) => Layer.succeed(TurnProjectionPort, { - getLatestTurnState: () => Effect.succeed({ state, completed: state === "completed" || state === "error" }), -}); +const stub = (state: string) => + Layer.succeed(TurnProjectionPort, { + getLatestTurnState: () => + Effect.succeed({ state, completed: state === "completed" || state === "error" }), + }); const mk = (state: string) => it.layer(TurnStateReaderLive.pipe(Layer.provideMerge(stub(state)))); @@ -567,15 +629,16 @@ export interface TurnProjectionPortShape { ) => Effect.Effect<{ readonly state: string; readonly completed: boolean }>; } export class TurnProjectionPort extends Context.Service< - TurnProjectionPort, TurnProjectionPortShape + TurnProjectionPort, + TurnProjectionPortShape >()("t3/workflow/Services/TurnProjectionPort") {} export interface TurnStateReaderShape { readonly read: (threadId: ThreadId) => Effect.Effect; } -export class TurnStateReader extends Context.Service< - TurnStateReader, TurnStateReaderShape ->()("t3/workflow/Services/TurnStateReader") {} +export class TurnStateReader extends Context.Service()( + "t3/workflow/Services/TurnStateReader", +) {} ``` ```typescript @@ -583,7 +646,10 @@ export class TurnStateReader extends Context.Service< import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { - TurnProjectionPort, TurnStateReader, type TurnState, type TurnStateReaderShape, + TurnProjectionPort, + TurnStateReader, + type TurnState, + type TurnStateReaderShape, } from "../Services/TurnStateReader.ts"; const make = Effect.gen(function* () { @@ -592,8 +658,7 @@ const make = Effect.gen(function* () { port.getLatestTurnState(threadId).pipe( Effect.map(({ state }): TurnState => { if (state === "completed") return { _tag: "completed" }; - if (state === "error" || state === "interrupted") - return { _tag: "failed", error: state }; + if (state === "error" || state === "interrupted") return { _tag: "failed", error: state }; return { _tag: "running" }; }), ); @@ -613,9 +678,12 @@ export const TurnProjectionPortLive = Layer.effect( const turns = yield* ProjectionTurnRepository; // from orchestration persistence return { getLatestTurnState: (threadId) => - turns.getLatestByThreadId(threadId).pipe( // confirm method name - Effect.map((t) => ({ state: t?.state ?? "pending", - completed: t?.state === "completed" || t?.state === "error" })), + turns.listByThreadId({ threadId }).pipe( + Effect.map((rows) => rows.at(-1)), + Effect.map((t) => ({ + state: t?.state ?? "pending", + completed: t?.state === "completed" || t?.state === "error", + })), Effect.orElseSucceed(() => ({ state: "pending", completed: false })), ), }; @@ -637,6 +705,7 @@ git commit -m "feat(workflow): add TurnStateReader (terminal state from projecti Persists a dispatch intent, drives `ProviderService` to start the turn, and confirms from `TurnStateReader` (provider progress), retrying idempotently on restart. **Files:** + - Create `workflow/Services/ProviderDispatchOutbox.ts` + `Layers/ProviderDispatchOutbox.ts` + test. - Define a `ProviderTurnPort` (stubbable wrapper over `ProviderService.startSession`+`sendTurn`). @@ -658,7 +727,7 @@ Persists a dispatch intent, drives `ProviderService` to start the turn, and conf ```typescript // apps/server/src/workflow/Services/ProviderDispatchOutbox.ts -import type { DispatchId, StepRunId, ThreadId, TicketId } from "@t3tools/contracts"; +import type { DispatchId, StepRunId, ThreadId, TicketId, TurnId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type { WorkflowEventStoreError } from "./Errors.ts"; @@ -668,8 +737,6 @@ export interface DispatchRequest { readonly ticketId: TicketId; readonly stepRunId: StepRunId; readonly threadId: ThreadId; - readonly commandId: string; - readonly messageId: string; readonly providerInstance: string; readonly model: string; readonly instruction: string; @@ -680,24 +747,26 @@ export interface DispatchRequest { export interface ProviderTurnPortShape { readonly ensureTurnStarted: ( req: DispatchRequest, - ) => Effect.Effect; + ) => Effect.Effect<{ readonly turnId: TurnId }, WorkflowEventStoreError>; } -export class ProviderTurnPort extends Context.Service< - ProviderTurnPort, ProviderTurnPortShape ->()("t3/workflow/Services/ProviderTurnPort") {} +export class ProviderTurnPort extends Context.Service()( + "t3/workflow/Services/ProviderTurnPort", +) {} export interface ProviderDispatchOutboxShape { - /** Persist intent + ensure provider turn is started (idempotent on dispatchId). */ + /** Persist intent + ensure provider turn is started (idempotent on dispatchId/status). */ readonly ensureStarted: (req: DispatchRequest) => Effect.Effect; /** Confirm dispatch once provider progress is terminal; returns the terminal turn state. */ readonly awaitTerminal: ( - dispatchId: DispatchId, threadId: ThreadId, + dispatchId: DispatchId, + threadId: ThreadId, ) => Effect.Effect<{ readonly ok: boolean; readonly error?: string }, WorkflowEventStoreError>; /** Recovery: re-ensure all unconfirmed rows. */ readonly recoverPending: () => Effect.Effect; } export class ProviderDispatchOutbox extends Context.Service< - ProviderDispatchOutbox, ProviderDispatchOutboxShape + ProviderDispatchOutbox, + ProviderDispatchOutboxShape >()("t3/workflow/Services/ProviderDispatchOutbox") {} ``` @@ -712,14 +781,20 @@ import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { - ProviderDispatchOutbox, ProviderTurnPort, type DispatchRequest, + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, type ProviderDispatchOutboxShape, } from "../Services/ProviderDispatchOutbox.ts"; import { TurnStateReader } from "../Services/TurnStateReader.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; const wrap = (e: Effect.Effect) => - e.pipe(Effect.mapError((c) => new WorkflowEventStoreError({ message: "dispatch op failed", cause: c }))); + e.pipe( + Effect.mapError( + (c) => new WorkflowEventStoreError({ message: "dispatch op failed", cause: c }), + ), + ); const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -728,32 +803,36 @@ const make = Effect.gen(function* () { const isConfirmed = (dispatchId: string) => wrap(sql<{ readonly status: string }>` - SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${dispatchId}`) - .pipe(Effect.map((r) => r[0]?.status === "confirmed")); + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${dispatchId}`).pipe( + Effect.map((r) => r[0]?.status === "confirmed"), + ); - const hasRow = (dispatchId: string) => - wrap(sql<{ readonly n: number }>` - SELECT COUNT(*) AS n FROM workflow_dispatch_outbox WHERE dispatch_id = ${dispatchId}`) - .pipe(Effect.map((r) => (r[0]?.n ?? 0) > 0)); + const getStatus = (dispatchId: string) => + wrap(sql<{ readonly status: "pending" | "started" | "confirmed" }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${dispatchId}`).pipe( + Effect.map((r) => r[0]?.status ?? null), + ); const ensureStarted: ProviderDispatchOutboxShape["ensureStarted"] = (req) => Effect.gen(function* () { - const exists = yield* hasRow(req.dispatchId); - if (!exists) { - yield* wrap(sql` - INSERT INTO workflow_dispatch_outbox - (dispatch_id, ticket_id, step_run_id, thread_id, command_id, message_id, - provider_instance, model, instruction, worktree_path, status, created_at) - VALUES (${req.dispatchId}, ${req.ticketId}, ${req.stepRunId}, ${req.threadId}, - ${req.commandId}, ${req.messageId}, ${req.providerInstance}, ${req.model}, - ${req.instruction}, ${req.worktreePath}, 'pending', ${new Date().toISOString()})`); - } - // Idempotency guard: only (re)start if provider progress is not yet observed. - const state = yield* turns.read(req.threadId); - if (state._tag === "running" && !(yield* isConfirmed(req.dispatchId))) { - // 'running' here means "no terminal yet" AND we haven't recorded a start — re-ensure. - yield* provider.ensureTurnStarted(req); - } + yield* wrap(sql` + INSERT INTO workflow_dispatch_outbox + (dispatch_id, ticket_id, step_run_id, thread_id, provider_instance, model, + instruction, worktree_path, status, created_at) + VALUES (${req.dispatchId}, ${req.ticketId}, ${req.stepRunId}, ${req.threadId}, + ${req.providerInstance}, ${req.model}, ${req.instruction}, ${req.worktreePath}, + 'pending', ${new Date().toISOString()}) + ON CONFLICT(dispatch_id) DO NOTHING`); + + const status = yield* getStatus(req.dispatchId); + if (status === "started" || status === "confirmed") return; + + // Only pending rows are allowed to call the provider. The production port should + // also check projection_turns for this deterministic threadId before sending. + const { turnId } = yield* provider.ensureTurnStarted(req); + yield* wrap(sql` + UPDATE workflow_dispatch_outbox SET status = 'started', turn_id = ${turnId}, + started_at = ${new Date().toISOString()} WHERE dispatch_id = ${req.dispatchId}`); }); const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => @@ -778,11 +857,14 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const rows = yield* wrap(sql` SELECT dispatch_id AS "dispatchId", ticket_id AS "ticketId", step_run_id AS "stepRunId", - thread_id AS "threadId", command_id AS "commandId", message_id AS "messageId", - provider_instance AS "providerInstance", model, instruction, + thread_id AS "threadId", provider_instance AS "providerInstance", model, instruction, worktree_path AS "worktreePath", status - FROM workflow_dispatch_outbox WHERE status = 'pending'`); - yield* Effect.forEach(rows, (r) => ensureStarted(r), { discard: true }); + FROM workflow_dispatch_outbox WHERE status != 'confirmed'`); + yield* Effect.forEach( + rows, + (r) => (r.status === "pending" ? ensureStarted(r) : Effect.void), + { discard: true }, + ); }); return { ensureStarted, awaitTerminal, recoverPending } satisfies ProviderDispatchOutboxShape; @@ -794,7 +876,7 @@ export const ProviderDispatchOutboxLive = Layer.effect(ProviderDispatchOutbox, m The production `ProviderTurnPort` wraps `ProviderService`: ```typescript -// production port — confirm exact ProviderSessionStartInput / ProviderSendTurnInput fields +// production port — uses the real ProviderSessionStartInput / ProviderSendTurnInput fields export const ProviderTurnPortLive = Layer.effect( ProviderTurnPort, Effect.gen(function* () { @@ -803,20 +885,29 @@ export const ProviderTurnPortLive = Layer.effect( ensureTurnStarted: (req) => Effect.gen(function* () { yield* providerSvc.startSession(req.threadId, { - // cwd: req.worktreePath, providerInstanceId: req.providerInstance, modelSelection ... + threadId: req.threadId, + providerInstanceId: req.providerInstance as never, + cwd: req.worktreePath as never, + modelSelection: { instanceId: req.providerInstance, model: req.model } as never, + runtimeMode: "full-access", } as never); - yield* providerSvc.sendTurn({ - // threadId: req.threadId, commandId: req.commandId, message: { messageId, role:'user', text: req.instruction, attachments: [] }, modelSelection ... + const turn = yield* providerSvc.sendTurn({ + threadId: req.threadId, + input: req.instruction as never, + modelSelection: { instanceId: req.providerInstance, model: req.model } as never, } as never); + return { turnId: turn.turnId }; }).pipe( - Effect.mapError((cause) => new WorkflowEventStoreError({ message: "provider start failed", cause })), + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "provider start failed", cause }), + ), ), }; }), ); ``` -> Implementer: `ProviderTurnPortLive` is the bridge to ACP. Fill `startSession`/`sendTurn` inputs from the verified `ProviderSessionStartInput`/`ProviderSendTurnInput` schemas (worktree cwd, provider instance, model selection, message text). Guard `startSession` so re-running on an existing session is a no-op or tolerated (provider may already have a session). All polling/idempotency logic above is unit-tested via the stub port. +> Implementer: `ProviderTurnPortLive` is the bridge to ACP. Fill `startSession`/`sendTurn` inputs from the verified schemas: `startSession(threadId, { threadId, providerInstanceId, cwd, modelSelection, runtimeMode, ... })` and `sendTurn({ threadId, input, modelSelection, interactionMode? })`. There are no `commandId`/`messageId` fields in the provider facade. Before sending, check `ProjectionTurnRepository.listByThreadId({ threadId })` / pending-start rows so a crash after provider intake but before the outbox row flips to `started` does not create a duplicate turn. - [ ] **Step 5: Run → PASS. Commit.** @@ -832,6 +923,7 @@ git commit -m "feat(workflow): add durable provider-dispatch outbox" Composes lease + setup + dispatch + terminal-state into one agent-step execution. Approval steps remain handled by the engine; this executor handles only `agent` steps (per the M2 port contract). **Files:** + - Create `workflow/Layers/RealStepExecutor.ts` + test. - Needs a worktree port (stub for tests): `WorktreePort` over `GitWorkflowService.createWorktree`. @@ -859,7 +951,8 @@ import type * as Effect from "effect/Effect"; import type { WorkflowEventStoreError } from "./Errors.ts"; export interface WorktreeHandle { - readonly worktreeRef: string; readonly path: string; + readonly worktreeRef: string; + readonly path: string; } export interface WorktreePortShape { /** Idempotent: returns the ticket's worktree, creating it on first call. */ @@ -899,7 +992,11 @@ const make = Effect.gen(function* () { // Setup gate (idempotent per ticket; a 'completed' row short-circuits in the service). const setupRunId = yield* ids.eventId(); // reuse id generator for a setup run id const setupResult = yield* setup.runSetup( - ctx.ticketId, wt.worktreeRef, wt.path, setupRunId as never); + ctx.ticketId, + wt.worktreeRef, + wt.path, + setupRunId as never, + ); if (setupResult.status !== "completed") { return { _tag: "failed", error: `setup ${setupResult.status}` } as StepOutcome; } @@ -909,31 +1006,39 @@ const make = Effect.gen(function* () { const dispatchId = yield* ids.eventId(); const threadId = yield* ids.eventId(); // one thread per step - const commandId = yield* ids.eventId(); - const messageId = yield* ids.eventId(); - const instruction = typeof ctx.step.instruction === "string" - ? ctx.step.instruction - : `@@file:${ctx.step.instruction.file}`; // M5/loader resolves file → text; here pass marker + const instruction = + typeof ctx.step.instruction === "string" + ? ctx.step.instruction + : `@@file:${ctx.step.instruction.file}`; // M5/loader resolves file → text; here pass marker yield* dispatch.ensureStarted({ - dispatchId: dispatchId as never, ticketId: ctx.ticketId, stepRunId: ctx.stepRunId, - threadId: threadId as never, commandId: commandId as string, messageId: messageId as string, - providerInstance: ctx.step.agent.instance as string, model: ctx.step.agent.model as string, - instruction, worktreePath: wt.path, + dispatchId: dispatchId as never, + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + threadId: threadId as never, + providerInstance: ctx.step.agent.instance as string, + model: ctx.step.agent.model as string, + instruction, + worktreePath: wt.path, }); - const result = yield* dispatch.awaitTerminal(dispatchId as never, threadId as never); - - // Release the lease iff still ours (fencing). - yield* Effect.whenEffect( - lease.isValid(wt.worktreeRef, acquired.fenceToken), - lease.release(wt.worktreeRef, acquired.fenceToken), + const releaseIfStillOwner = lease.isValid(wt.worktreeRef, acquired.fenceToken).pipe( + Effect.flatMap((valid) => + valid ? lease.release(wt.worktreeRef, acquired.fenceToken) : Effect.void, + ), + Effect.orElseSucceed(() => undefined), ); - return (result.ok - ? { _tag: "completed" } - : { _tag: "failed", error: result.error ?? "turn failed" }) as StepOutcome; - }).pipe(Effect.orElseSucceed(() => ({ _tag: "failed", error: "executor error" }) as StepOutcome)); + const result = yield* dispatch + .awaitTerminal(dispatchId as never, threadId as never) + .pipe(Effect.ensuring(releaseIfStillOwner)); + + return ( + result.ok ? { _tag: "completed" } : { _tag: "failed", error: result.error ?? "turn failed" } + ) as StepOutcome; + }).pipe( + Effect.orElseSucceed(() => ({ _tag: "failed", error: "executor error" }) as StepOutcome), + ); return { execute } satisfies StepExecutorShape; }); @@ -941,7 +1046,7 @@ const make = Effect.gen(function* () { export const RealStepExecutorLive = Layer.effect(StepExecutor, make); ``` -> Notes: (1) The instruction-file → text resolution is deferred to the loader (M5) / a small helper; for M3 the marker is acceptable since tests use inline instructions. (2) `Effect.whenEffect` predicate/usage — confirm the exact combinator name in the beta (`Effect.whenEffect` or `Effect.when`); the intent is "release only if our fence is still current." (3) Provider questions/approvals surfacing as `awaiting_user`: in M3 the simplest correct behavior is — if `awaitTerminal` observes the turn parked on a pending question (extend `TurnStateReader` to detect a pending-approval/user-input projection and return a `{_tag:'awaiting_user'}` turn state), return `{ _tag: "awaiting_user", waitingReason }`. The M2 engine already parks on that outcome and resumes via `resolveApproval`, which for a volatile provider question maps to `ProviderService.respondToUserInput`/`respondToRequest`. Wire that response path in Task 7. +> Notes: (1) The instruction-file → text resolution is deferred to the loader (M5) / a small helper; for M3 the marker is acceptable since tests use inline instructions. (2) Lease release must be in `Effect.ensuring` so errors while dispatching or polling do not leave the worktree fenced forever; avoid nonexistent `Effect.whenEffect`. (3) Provider questions/approvals surfacing as `awaiting_user`: in M3 the simplest correct behavior is — if `awaitTerminal` observes the turn parked on a pending question (extend `TurnStateReader` to detect a pending-approval/user-input projection and return a `{_tag:'awaiting_user'}` turn state), return `{ _tag: "awaiting_user", waitingReason }`. The M2 engine already parks on that outcome and resumes via `resolveApproval`, which for a volatile provider question maps to `ProviderService.respondToUserInput`/`respondToRequest` with the persisted `requestId`. Wire that response path in Task 7. - [ ] **Step 4: Run → PASS. Commit.** @@ -957,6 +1062,7 @@ git commit -m "feat(workflow): add RealStepExecutor (worktree+lease+setup+dispat Make `approval` steps durable (engine-owned, already persisted as workflow events in M2 — confirm they survive restart) and wire provider-question responses to `ProviderService`. **Files:** + - Create `workflow/Layers/DurableApprovalResume.ts` (recovery of parked approvals from `workflow_events`) + test. - Extend the engine's `resolveApproval` to route provider-question answers to a `ProviderResponsePort`. @@ -976,12 +1082,14 @@ Test that, given a `workflow_events` log where a `StepAwaitingUser` has no later // sketch — apps/server/src/workflow/Services/ProviderResponsePort.ts export interface ProviderResponsePortShape { readonly respond: (input: { - readonly threadId: ThreadId; readonly approved: boolean; readonly text?: string; + readonly threadId: ThreadId; + readonly approved: boolean; + readonly text?: string; }) => Effect.Effect; } ``` -> Implementer: the production `ProviderResponsePort` calls `ProviderService.respondToUserInput` (for questions) or `respondToRequest` (for tool approvals) — confirm which the agent step is parked on via the pending-approval projection, and pick the matching method. +> Implementer: the production `ProviderResponsePort` calls `ProviderService.respondToUserInput({ threadId, requestId, answers })` for structured questions or `respondToRequest({ threadId, requestId, decision })` for tool approvals. Persist the provider `requestId` with the parked step; neither method can be called with only `threadId` + boolean. - [ ] **Step 5: Run → PASS. Commit.** @@ -997,6 +1105,7 @@ git commit -m "feat(workflow): durable approvals + provider-question response wi On boot, reconcile in-flight state per spec §7.9. **Files:** + - Create `workflow/Services/WorkflowRecovery.ts` + `Layers/WorkflowRecovery.ts` + test. - [ ] **Steps:** Implement `recover()` that runs, in order: @@ -1019,6 +1128,7 @@ git commit -m "feat(workflow): add startup recovery reconciliation" ## Task 9: Mock ACP provider + end-to-end integration with restart; aggregate runtime layer **Files:** + - Create `workflow/Layers/MockAcpProvider.ts` — a `ProviderTurnPort` + `TurnProjectionPort` test double driven by a script (advance a thread from running → completed on demand, or park on a question). - Create `workflow/WorkflowRuntimeLive.ts` — aggregates: M2 engine (with `RealStepExecutorLive` instead of the stub) + lease + setup + dispatch + turn-state + recovery, plus the production ports (`ProviderTurnPortLive`, `TurnProjectionPortLive`, `SetupTerminalPortLive`, real `WorktreePort`). - Create `workflow/Layers/WorkflowRuntime.integration.test.ts`. @@ -1084,6 +1194,6 @@ git commit -m "feat(workflow): mock ACP provider + e2e runtime with restart reco ## Notes for the implementer - **Ports are the test seams.** Every external dependency (provider, terminal/setup, worktree, turn projection) is behind a small port with a stub layer for unit tests and a `*Live` layer that bridges to the real t3code service. Keep the orchestration logic in the service tested against stubs; keep the bridging in the `*Live` ports, where you confirm exact upstream signatures. -- **Confirm these real signatures while wiring `*Live` ports:** `ProviderService.startSession/sendTurn/respondToUserInput/respondToRequest` inputs; `ProjectionTurnRepository` latest-turn-by-thread query + `state` values; `ProjectSetupScriptRunner.runForThread` + `TerminalManager` exit-event stream (`TerminalExitedEvent`); `GitWorkflowService.createWorktree` return (`{ worktree: { path, refName } }`). +- **Confirm these real signatures while wiring `*Live` ports:** `ProviderService.startSession/sendTurn/respondToUserInput/respondToRequest` inputs; `ProjectionTurnRepository.listByThreadId`/`getByTurnId` + `state` values; `ProjectSetupScriptRunner.runForThread` result (`no-script` or `started`) + callback-based `TerminalManager.subscribe(listener)`; `GitWorkflowService.createWorktree` return (`{ worktree: { path, refName } }`). - **Polling vs subscription:** `awaitTerminal` polls `projection_turns` (restart-safe, simple). If you'd rather subscribe to the projection stream, do it in `*Live` without changing the outbox logic — but keep confirmation based on persisted state. - **Idempotency is the whole point of §7.8:** never confirm on command intake; only on terminal provider progress. The restart integration test (Task 9) is the guard — keep it. diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md index 72ee67d5d18..897fd0b1d53 100644 --- a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md @@ -11,12 +11,14 @@ **Spec:** `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md` (§7.10). **Reference signatures (verified):** + - `CheckpointStore.captureCheckpoint({ cwd, checkpointRef })`, `diffCheckpoints({ cwd, fromCheckpointRef, toCheckpointRef, ignoreWhitespace })`, `hasCheckpointRef`, `deleteCheckpointRefs` — `apps/server/src/checkpointing/Services/CheckpointStore.ts`. - Ref format util `checkpointRefForThreadTurn` and `CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"` — `apps/server/src/checkpointing/Utils.ts`. -- Working-tree diff: `git diff --patch --minimal HEAD --` and ref-range `git diff --patch ...HEAD` — `GitVcsDriverCore.ts` (`getReviewDiffPreview`). +- Working-tree diff: call the public `GitVcsDriver.execute(input)` from `apps/server/src/vcs/GitVcsDriver.ts`. Do not depend on `GitVcsDriverCore.executeGit`, which is internal. - Per-file summary parser: `parseTurnDiffFilesFromUnifiedDiff(diff) → [{ path, additions, deletions }]` — `apps/server/src/checkpointing/Diffs.ts`. - Diff result contract shape: `ReviewDiffPreviewSource` — `packages/contracts/src/review.ts`. -- Test repo helper `initRepoWithCommit(cwd)` — `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts`. +- `CheckpointRef` is exported from `@t3tools/contracts` through `baseSchemas.ts`/`index.ts`. +- Temp repo helpers in `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts` are local to that test file; copy small equivalents into workflow tests instead of importing them. **Depends on:** M1–M3. **Out of scope:** RPC/UI exposure of the diff (M5 surfaces it). @@ -25,12 +27,14 @@ ## File Structure **Create:** + - `apps/server/src/persistence/Migrations/0DD_WorkflowStepRefs.ts` — add `pre_checkpoint_ref`, `post_checkpoint_ref` to `projection_step_run`. - `apps/server/src/workflow/ticketRefs.ts` — pure ref-name helpers. - `apps/server/src/workflow/Services/TicketCheckpointService.ts` + `Layers/TicketCheckpointService.ts` + test. - `apps/server/src/workflow/Services/TicketDiffQuery.ts` + `Layers/TicketDiffQuery.ts` + test. **Modify:** + - `apps/server/src/persistence/Migrations.ts` — register the migration. - `packages/contracts/src/workflow.ts` — add `TicketDiff` result schema. - `apps/server/src/workflow/Layers/WorkflowEngine.ts` — capture baseline on ticket creation. @@ -54,8 +58,10 @@ describe("ticketRefs", () => { assert.equal(ticketBaseRef("t-1" as never), "refs/t3/tickets/dC0x/base"); }); it("builds pre/post step refs", () => { - assert.equal(ticketStepRef("t-1" as never, "sr-1" as never, "pre"), - "refs/t3/tickets/dC0x/step/c3ItMQ/pre"); + assert.equal( + ticketStepRef("t-1" as never, "sr-1" as never, "pre"), + "refs/t3/tickets/dC0x/step/c3ItMQ/pre", + ); }); }); ``` @@ -79,7 +85,9 @@ export const ticketBaseRef = (ticketId: TicketId): string => `${TICKET_REFS_PREFIX}/${enc(ticketId as string)}/base`; export const ticketStepRef = ( - ticketId: TicketId, stepRunId: StepRunId, kind: "pre" | "post", + ticketId: TicketId, + stepRunId: StepRunId, + kind: "pre" | "post", ): string => `${TICKET_REFS_PREFIX}/${enc(ticketId as string)}/step/${enc(stepRunId as string)}/${kind}`; ``` @@ -185,7 +193,7 @@ import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts" import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; // Reuse the checkpointing test helpers for a temp repo + the real CheckpointStore layer. import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; -// import { makeTmpDir, initRepoWithCommit } from checkpointing test utils (or inline minimal git setup) +// Inline minimal makeTmpDir/initRepoWithCommit helpers; the checkpointing test helpers are not exported. const layer = it.layer( TicketCheckpointServiceLive.pipe(Layer.provideMerge(CheckpointStoreLive /* + its deps */)), @@ -205,7 +213,7 @@ layer("TicketCheckpointService", (it) => { }); ``` -> Use the real `initRepoWithCommit`/`makeTmpDir` helpers from `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts` (import or copy minimal versions). Checkpoint capture needs a real git repo. +> Copy minimal `initRepoWithCommit`/`makeTmpDir` helpers into this test. The helpers in `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts` are not exported. Checkpoint capture needs a real git repo. - [ ] **Step 2: Run → FAIL.** @@ -220,17 +228,23 @@ import type { WorkflowEventStoreError } from "./Errors.ts"; export interface TicketCheckpointServiceShape { readonly captureBaseline: ( - ticketId: TicketId, cwd: string, + ticketId: TicketId, + cwd: string, ) => Effect.Effect; // returns the baseline ref readonly hasBaseline: ( - ticketId: TicketId, cwd: string, + ticketId: TicketId, + cwd: string, ) => Effect.Effect; readonly captureStep: ( - ticketId: TicketId, stepRunId: StepRunId, cwd: string, kind: "pre" | "post", + ticketId: TicketId, + stepRunId: StepRunId, + cwd: string, + kind: "pre" | "post", ) => Effect.Effect; // returns the step ref } export class TicketCheckpointService extends Context.Service< - TicketCheckpointService, TicketCheckpointServiceShape + TicketCheckpointService, + TicketCheckpointServiceShape >()("t3/workflow/Services/TicketCheckpointService") {} ``` @@ -243,13 +257,18 @@ import * as Layer from "effect/Layer"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; // confirm where CheckpointRef is exported import { - TicketCheckpointService, type TicketCheckpointServiceShape, + TicketCheckpointService, + type TicketCheckpointServiceShape, } from "../Services/TicketCheckpointService.ts"; import { ticketBaseRef, ticketStepRef } from "../ticketRefs.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; const wrap = (e: Effect.Effect) => - e.pipe(Effect.mapError((c) => new WorkflowEventStoreError({ message: "checkpoint op failed", cause: c }))); + e.pipe( + Effect.mapError( + (c) => new WorkflowEventStoreError({ message: "checkpoint op failed", cause: c }), + ), + ); const make = Effect.gen(function* () { const checkpoints = yield* CheckpointStore; @@ -262,9 +281,19 @@ const make = Effect.gen(function* () { }); const hasBaseline: TicketCheckpointServiceShape["hasBaseline"] = (ticketId, cwd) => - wrap(checkpoints.hasCheckpointRef({ cwd, checkpointRef: CheckpointRef.make(ticketBaseRef(ticketId)) })); - - const captureStep: TicketCheckpointServiceShape["captureStep"] = (ticketId, stepRunId, cwd, kind) => + wrap( + checkpoints.hasCheckpointRef({ + cwd, + checkpointRef: CheckpointRef.make(ticketBaseRef(ticketId)), + }), + ); + + const captureStep: TicketCheckpointServiceShape["captureStep"] = ( + ticketId, + stepRunId, + cwd, + kind, + ) => Effect.gen(function* () { const ref = ticketStepRef(ticketId, stepRunId, kind); yield* wrap(checkpoints.captureCheckpoint({ cwd, checkpointRef: CheckpointRef.make(ref) })); @@ -277,7 +306,7 @@ const make = Effect.gen(function* () { export const TicketCheckpointServiceLive = Layer.effect(TicketCheckpointService, make); ``` -> Confirm `CheckpointStore.hasCheckpointRef` input shape (`{ cwd, checkpointRef }`) and where `CheckpointRef` is exported (likely contracts or checkpointing). `captureCheckpoint` snapshots the current worktree into the ref — exactly what baseline (at creation) and pre/post (around a step) need. +> `CheckpointStore.hasCheckpointRef` takes `{ cwd, checkpointRef }`; use `CheckpointRef` from `@t3tools/contracts`. `captureCheckpoint` snapshots the current worktree into the ref — exactly what baseline (at creation) and pre/post (around a step) need. - [ ] **Step 5: Run → PASS. Commit.** @@ -297,12 +326,14 @@ git commit -m "feat(workflow): add TicketCheckpointService (baseline + per-step ```typescript // append to packages/contracts/src/workflow.ts export const TicketDiffFile = Schema.Struct({ - path: Schema.String, additions: Schema.Int, deletions: Schema.Int, + path: Schema.String, + additions: Schema.Int, + deletions: Schema.Int, }); export const TicketDiff = Schema.Struct({ ticketId: TicketId, baseRef: Schema.String, - patch: Schema.String, // raw unified diff (may be truncated) + patch: Schema.String, // raw unified diff (may be truncated) files: Schema.Array(TicketDiffFile), truncated: Schema.Boolean, }); @@ -329,21 +360,27 @@ import type { WorkflowEventStoreError } from "./Errors.ts"; /** Stubbable port over the git working-tree diff plumbing. */ export interface WorktreeDiffPortShape { readonly diffRefToWorktree: (input: { - readonly cwd: string; readonly baseRef: string; - }) => Effect.Effect<{ readonly patch: string; readonly truncated: boolean }, WorkflowEventStoreError>; + readonly cwd: string; + readonly baseRef: string; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + WorkflowEventStoreError + >; } -export class WorktreeDiffPort extends Context.Service< - WorktreeDiffPort, WorktreeDiffPortShape ->()("t3/workflow/Services/WorktreeDiffPort") {} +export class WorktreeDiffPort extends Context.Service()( + "t3/workflow/Services/WorktreeDiffPort", +) {} export interface TicketDiffQueryShape { readonly getTicketDiff: ( - ticketId: TicketId, cwd: string, baseRef: string, + ticketId: TicketId, + cwd: string, + baseRef: string, ) => Effect.Effect; } -export class TicketDiffQuery extends Context.Service< - TicketDiffQuery, TicketDiffQueryShape ->()("t3/workflow/Services/TicketDiffQuery") {} +export class TicketDiffQuery extends Context.Service()( + "t3/workflow/Services/TicketDiffQuery", +) {} ``` - [ ] **Step 4: Layer** @@ -353,8 +390,11 @@ export class TicketDiffQuery extends Context.Service< import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; import { - TicketDiffQuery, WorktreeDiffPort, type TicketDiffQueryShape, + TicketDiffQuery, + WorktreeDiffPort, + type TicketDiffQueryShape, } from "../Services/TicketDiffQuery.ts"; const make = Effect.gen(function* () { @@ -371,30 +411,67 @@ const make = Effect.gen(function* () { export const TicketDiffQueryLive = Layer.effect(TicketDiffQuery, make); ``` -Production `WorktreeDiffPort` shells out to git (mirror `GitVcsDriverCore.getReviewDiffPreview`): +Production `WorktreeDiffPort` shells out through the public VCS service and appends no-index diffs for untracked files: ```typescript // production port — diff the baseline ref against the live working tree export const WorktreeDiffPortLive = Layer.effect( WorktreeDiffPort, Effect.gen(function* () { - const git = yield* GitVcsDriverCore; // or the executeGit helper / GitManager + const git = yield* GitVcsDriver; return { diffRefToWorktree: ({ cwd, baseRef }) => - // git diff --patch --minimal -- (ref vs working tree, incl. uncommitted) - git.execute(/* operation */ "workflow.ticketDiff", cwd, - ["diff", "--patch", "--minimal", `${baseRef}^{commit}`, "--"], - { maxOutputBytes: 120_000, appendTruncationMarker: true } as never).pipe( - Effect.map((r: { stdout: string; stdoutTruncated?: boolean }) => - ({ patch: r.stdout, truncated: r.stdoutTruncated ?? false })), - Effect.mapError((cause) => new WorkflowEventStoreError({ message: "ticket diff failed", cause })), + Effect.gen(function* () { + const tracked = yield* git.execute({ + operation: "WorkflowTicketDiff.tracked", + cwd, + args: ["diff", "--patch", "--minimal", `${baseRef}^{commit}`, "--"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }); + const untrackedList = yield* git + .execute({ + operation: "WorkflowTicketDiff.untracked.list", + cwd, + args: ["ls-files", "--others", "--exclude-standard", "-z"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }) + .pipe(Effect.orElseSucceed(() => ({ stdout: "", stdoutTruncated: false }) as const)); + const untrackedPaths = untrackedList.stdout.split("\0").filter((p) => p.length > 0); + const untrackedDiffs = yield* Effect.forEach( + untrackedPaths, + (path) => + git.execute({ + operation: "WorkflowTicketDiff.untracked.diff", + cwd, + args: ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", path], + allowNonZeroExit: true, + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }), + { concurrency: 4 }, + ); + return { + patch: [tracked.stdout.trimEnd(), ...untrackedDiffs.map((r) => r.stdout.trimEnd())] + .filter((part) => part.length > 0) + .join("\n"), + truncated: + tracked.stdoutTruncated || + untrackedList.stdoutTruncated || + untrackedDiffs.some((r) => r.stdoutTruncated), + }; + }).pipe( + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "ticket diff failed", cause }), + ), ), }; }), ); ``` -> Implementer: confirm the exact git-exec helper (`executeGit`/`GitVcsDriverCore.execute`) and its options (`maxOutputBytes`, truncation flag) from `GitVcsDriverCore.ts`. The command `git diff --patch ^{commit} --` diffs the baseline commit against the working tree (uncommitted changes included), which is the accumulated ticket diff per §7.10. Untracked files: mirror how `getReviewDiffPreview` includes untracked (it appends a second diff for untracked paths) if you want them shown. +> Implementer: use `GitVcsDriver.execute({ operation, cwd, args, maxOutputBytes, appendTruncationMarker, allowNonZeroExit? })`. The command `git diff --patch ^{commit} --` includes tracked staged/unstaged changes, but Git excludes untracked files; the accumulated ticket diff must append the `git diff --no-index -- /dev/null ` output for untracked paths, mirroring `readUntrackedReviewDiffs` in `GitVcsDriverCore.ts` without importing that internal helper. - [ ] **Step 5: Run → PASS. Commit.** @@ -415,21 +492,35 @@ In `WorkflowEngine` `createTicket`, after the worktree exists (note: worktree is ```typescript // in RealStepExecutor.execute, after ensureWorktree: -const hasBase = yield* ticketCheckpoints.hasBaseline(ctx.ticketId, wt.path); -if (!hasBase) yield* ticketCheckpoints.captureBaseline(ctx.ticketId, wt.path); +const captureBaselineIfMissing = Effect.gen(function* () { + const hasBase = yield* ticketCheckpoints.hasBaseline(ctx.ticketId, wt.path); + if (!hasBase) yield* ticketCheckpoints.captureBaseline(ctx.ticketId, wt.path); +}); ``` - [ ] **Step 2: Pre/post around the agent turn + persist refs** ```typescript // in RealStepExecutor.execute, around the dispatch/await: -const preRef = yield* ticketCheckpoints.captureStep(ctx.ticketId, ctx.stepRunId, wt.path, "pre"); -// ... ensureStarted + awaitTerminal ... -const postRef = yield* ticketCheckpoints.captureStep(ctx.ticketId, ctx.stepRunId, wt.path, "post"); -// emit StepRefsCaptured via the committer so projection_step_run records them: -yield* committer.commit({ type: "StepRefsCaptured", eventId: yield* ids.eventId(), - ticketId: ctx.ticketId, occurredAt: new Date().toISOString() as never, - payload: { stepRunId: ctx.stepRunId, preRef, postRef } } as never); +const captureStepRefs = Effect.gen(function* () { + const preRef = yield* ticketCheckpoints.captureStep(ctx.ticketId, ctx.stepRunId, wt.path, "pre"); + // ... ensureStarted + awaitTerminal ... + // Capture post even when the provider turn failed, as long as the worktree exists. + const postRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + wt.path, + "post", + ); + // emit StepRefsCaptured via the committer so projection_step_run records them: + yield* committer.commit({ + type: "StepRefsCaptured", + eventId: yield* ids.eventId(), + ticketId: ctx.ticketId, + occurredAt: new Date().toISOString() as never, + payload: { stepRunId: ctx.stepRunId, preRef, postRef }, + } as never); +}); ``` Add `TicketCheckpointService` and `WorkflowEventCommitter` to `RealStepExecutor`'s dependencies. @@ -457,5 +548,5 @@ git commit -m "feat(workflow): capture ticket baseline + per-step refs during ex ## Notes for the implementer - **Reuse, don't reinvent:** capture uses `CheckpointStore.captureCheckpoint` verbatim (just a different ref namespace). Only the **base→worktree** diff is new, because `CheckpointStore.diffCheckpoints` is ref-to-ref; the accumulated diff must include uncommitted working-tree state. -- **Real-git tests:** checkpoint/diff tests need a temp repo. Reuse `initRepoWithCommit`/`makeTmpDir` from `checkpointing/Layers/CheckpointStore.test.ts`. Keep the executor's own test hermetic with a stub `TicketCheckpointService`. +- **Real-git tests:** checkpoint/diff tests need a temp repo. Copy minimal `initRepoWithCommit`/`makeTmpDir` helpers; the helpers in `checkpointing/Layers/CheckpointStore.test.ts` are local to that file. Keep the executor's own test hermetic with a stub `TicketCheckpointService`. - **Truncation:** the accumulated diff can be large; carry the `truncated` flag through to the UI (M5) like `getReviewDiffPreview` does. diff --git a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md index dde6f2d4669..72dc2424899 100644 --- a/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md +++ b/docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md @@ -10,23 +10,30 @@ **Spec:** §6 (file), §8 (UI). **Depends on:** M1–M4. -**Reference (verified):** RPC constants in `packages/contracts/src/orchestration.ts`; `Rpc.make({payload,success,error,stream?})` in `rpc.ts`; handlers + `RPC_REQUIRED_SCOPE` in `ws.ts`; client `transport.request` / subscribe in `packages/client-runtime/src/`; zustand `apps/web/src/store.ts`; dnd-kit in `apps/web/src/components/Sidebar.tsx`; CVA in `apps/web/src/components/ui/button.tsx`; tests via `vite-plus/test`. +**Reference (verified):** RPC constants in `packages/contracts/src/orchestration.ts`; `Rpc.make({payload,success,error,stream?})` and central `WsRpcGroup = RpcGroup.make(...)` in `packages/contracts/src/rpc.ts`; auth scopes in `packages/contracts/src/auth.ts`; handlers + `RPC_REQUIRED_SCOPE` in `apps/server/src/ws.ts`; manual client wrappers in `packages/client-runtime/src/wsRpcClient.ts`; `EnvironmentApi` in `packages/contracts/src/ipc.ts` and `apps/web/src/environmentApi.ts`; zustand `apps/web/src/store.ts`; dnd-kit in `apps/web/src/components/Sidebar.tsx`; CVA in `apps/web/src/components/ui/button.tsx`; tests via `vite-plus/test`. --- ## File Structure **Server — create:** + - `apps/server/src/workflow/Services/WorkflowFileLoader.ts` + `Layers/WorkflowFileLoader.ts` + test. - `apps/server/src/workflow/Services/WorkflowBoardEvents.ts` — a PubSub of board-projection deltas for `subscribeBoard`. - `apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts` — the `WsRpcGroup` fragment for workflow methods. **Server — modify:** -- `packages/contracts/src/workflow.ts` — `WORKFLOW_WS_METHODS`, `Rpc.make` defs, board-snapshot + delta schemas, scopes. + +- `packages/contracts/src/workflow.ts` — `WORKFLOW_WS_METHODS`, workflow schemas, `WorkflowRpcError`, board-snapshot + delta schemas. +- `packages/contracts/src/rpc.ts` — import workflow RPC schema exports and add them to `WsRpcGroup`. +- `packages/contracts/src/auth.ts` — add workflow read/operate scopes and default grants. - `apps/server/src/ws.ts` — merge workflow handlers + `RPC_REQUIRED_SCOPE` entries. -- server runtime startup (`serverRuntimeStartup.ts` / `bootstrap.ts`) — provide `WorkflowRuntimeLive`, load board files, run `WorkflowRecovery.recover()`. +- `apps/server/src/server.ts` / startup layer — provide `WorkflowRuntimeLive`, load board files, run `WorkflowRecovery.recover()` once during boot. +- `packages/client-runtime/src/wsRpcClient.ts` — add manual `client.workflow.*` wrappers. +- `packages/contracts/src/ipc.ts` and `apps/web/src/environmentApi.ts` — expose workflow methods through `EnvironmentApi`. **Web — create:** + - `apps/web/src/workflow/boardState.ts` — zustand slice + reducer for board projection. - `apps/web/src/workflow/boardRpc.ts` — client calls + subscription wiring. - `apps/web/src/routes/_chat.$environmentId.board.tsx` — board route. @@ -37,7 +44,7 @@ ## Task 1: Contracts — methods, schemas, scopes -**Files:** Modify `packages/contracts/src/workflow.ts` (and the rpc aggregation file where `Rpc.make` groups live — mirror orchestration). +**Files:** Modify `packages/contracts/src/workflow.ts`, `packages/contracts/src/rpc.ts`, and `packages/contracts/src/auth.ts`. - [ ] **Step 1: Add method constants + board read schemas + scopes** @@ -57,13 +64,25 @@ export const WORKFLOW_WS_METHODS = { // Board snapshot/delta the UI consumes export const BoardTicketView = Schema.Struct({ - ticketId: TicketId, boardId: BoardId, title: Schema.String, - currentLaneKey: LaneKey, status: TicketStatus, + ticketId: TicketId, + boardId: BoardId, + title: Schema.String, + currentLaneKey: LaneKey, + status: TicketStatus, }); export const BoardSnapshot = Schema.Struct({ - board: Schema.Struct({ boardId: BoardId, name: Schema.String, - lanes: Schema.Array(Schema.Struct({ key: LaneKey, name: Schema.String, - entry: LaneEntry, terminal: Schema.optional(Schema.Boolean) })) }), + board: Schema.Struct({ + boardId: BoardId, + name: Schema.String, + lanes: Schema.Array( + Schema.Struct({ + key: LaneKey, + name: Schema.String, + entry: LaneEntry, + terminal: Schema.optional(Schema.Boolean), + }), + ), + }), tickets: Schema.Array(BoardTicketView), }); export const BoardStreamItem = Schema.Union([ @@ -73,44 +92,53 @@ export const BoardStreamItem = Schema.Union([ export type BoardStreamItem = typeof BoardStreamItem.Type; ``` -Define auth scopes alongside the existing ones (mirror `AuthOrchestrationReadScope`/`OperateScope`): `AuthWorkflowReadScope`, `AuthWorkflowOperateScope`. +Define auth scopes in `packages/contracts/src/auth.ts` alongside the existing ones (mirror `AuthOrchestrationReadScope`/`OperateScope`): `AuthWorkflowReadScope`, `AuthWorkflowOperateScope`. Add both to `AuthEnvironmentScope`; add `AuthWorkflowReadScope` and `AuthWorkflowOperateScope` to `AuthStandardClientScopes` so the web client can call workflow RPCs by default. -- [ ] **Step 2: Add `Rpc.make` definitions** (in the file where orchestration RPCs are declared) +- [ ] **Step 2: Add `Rpc.make` definitions in `packages/contracts/src/rpc.ts` and include them in `WsRpcGroup`** ```typescript export const WsWorkflowSubscribeBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.subscribeBoard, { payload: Schema.Struct({ boardId: BoardId }), - success: BoardStreamItem, error: Schema.Union([/* WorkflowRpcError */, EnvironmentAuthorizationError]), + success: BoardStreamItem, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), stream: true, }); export const WsWorkflowCreateTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.createTicket, { - payload: Schema.Struct({ boardId: BoardId, title: Schema.String, - description: Schema.optional(Schema.String), initialLane: LaneKey }), + payload: Schema.Struct({ + boardId: BoardId, + title: Schema.String, + description: Schema.optional(Schema.String), + initialLane: LaneKey, + }), success: Schema.Struct({ ticketId: TicketId }), - error: Schema.Union([/* WorkflowRpcError */, EnvironmentAuthorizationError]), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), }); export const WsWorkflowMoveTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.moveTicket, { payload: Schema.Struct({ ticketId: TicketId, toLane: LaneKey }), - success: Schema.Void, error: Schema.Union([/* … */]), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), }); export const WsWorkflowResolveApprovalRpc = Rpc.make(WORKFLOW_WS_METHODS.resolveApproval, { payload: Schema.Struct({ stepRunId: StepRunId, approved: Schema.Boolean }), - success: Schema.Void, error: Schema.Union([/* … */]), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), }); export const WsWorkflowGetTicketDiffRpc = Rpc.make(WORKFLOW_WS_METHODS.getTicketDiff, { payload: Schema.Struct({ ticketId: TicketId }), - success: TicketDiff, error: Schema.Union([/* … */]), + success: TicketDiff, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), }); // + getBoard, getTicketDetail, runLane, registerBoardFromFile ``` Define a `WorkflowRpcError` tagged error (mirror `OrchestrationDispatchCommandError`). +Use `Schema.TaggedErrorClass`, not `Schema.TaggedError`. - [ ] **Step 3: Commit (typecheck the contracts package)** ```bash pnpm --filter @t3tools/contracts typecheck -git add packages/contracts/src/workflow.ts packages/contracts/src/rpc.ts +git add packages/contracts/src/workflow.ts packages/contracts/src/rpc.ts packages/contracts/src/auth.ts git commit -m "feat(workflow): add workflow.* RPC method + board schemas + scopes" ``` @@ -141,19 +169,23 @@ import type * as Effect from "effect/Effect"; export interface WorkflowFileLoaderShape { readonly loadAndRegister: (input: { - readonly boardId: BoardId; readonly projectId: ProjectId; - readonly filePath: string; readonly repoRoot: string; + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly filePath: string; + readonly repoRoot: string; }) => Effect.Effect; } export class WorkflowFileLoader extends Context.Service< - WorkflowFileLoader, WorkflowFileLoaderShape + WorkflowFileLoader, + WorkflowFileLoaderShape >()("t3/workflow/Services/WorkflowFileLoader") {} ``` The layer: read file (via `FileSystem` from `@effect/platform` as the rest of the server does) → parse JSON (use a **location-aware** parser for lint diagnostics; a plain `JSON.parse` is acceptable for v1 if you surface schema-cause messages) → `Schema.decodeUnknown(WorkflowDefinition)` → `lintWorkflowDefinition(def, { providerInstanceExists, instructionFileExists })` where: + - `providerInstanceExists` checks the configured provider instances (from `ServerSettings`/`ProviderInstanceRegistry`), - `instructionFileExists` checks `repoRoot + "/" + step.instruction.file` on disk. -→ compute a `workflowVersionHash` (sha256 of the file content) → `BoardRegistry.register` + `WorkflowReadModel.registerBoard`. + → compute a `workflowVersionHash` (sha256 of the file content) → `BoardRegistry.register` + `WorkflowReadModel.registerBoard`. - [ ] **Step 5: Commit** @@ -194,48 +226,78 @@ import * as Stream from "effect/Stream"; // inside makeWsRpcLayer, given workflowEngine, readModel, ticketDiffQuery, boardEvents, fileLoader: export const workflowRpcHandlers = (deps: { - engine; readModel; ticketDiff; boardEvents; fileLoader; observeRpcEffect; observeRpcStreamEffect; + engine; + readModel; + ticketDiff; + ticketWorktrees; + boardEvents; + fileLoader; + observeRpcEffect; + observeRpcStreamEffect; }) => ({ [WORKFLOW_WS_METHODS.createTicket]: (input) => - deps.observeRpcEffect(WORKFLOW_WS_METHODS.createTicket, + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createTicket, deps.engine.createTicket(input).pipe(Effect.map((ticketId) => ({ ticketId }))), - { "rpc.aggregate": "workflow" }), + { "rpc.aggregate": "workflow" }, + ), [WORKFLOW_WS_METHODS.moveTicket]: (input) => - deps.observeRpcEffect(WORKFLOW_WS_METHODS.moveTicket, - deps.engine.moveTicket(input.ticketId, input.toLane), { "rpc.aggregate": "workflow" }), + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.moveTicket, + deps.engine.moveTicket(input.ticketId, input.toLane), + { "rpc.aggregate": "workflow" }, + ), [WORKFLOW_WS_METHODS.resolveApproval]: (input) => - deps.observeRpcEffect(WORKFLOW_WS_METHODS.resolveApproval, - deps.engine.resolveApproval(input.stepRunId, input.approved), { "rpc.aggregate": "workflow" }), + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.resolveApproval, + deps.engine.resolveApproval(input.stepRunId, input.approved), + { "rpc.aggregate": "workflow" }, + ), [WORKFLOW_WS_METHODS.runLane]: (input) => - deps.observeRpcEffect(WORKFLOW_WS_METHODS.runLane, - deps.engine.runLane(input.ticketId), { "rpc.aggregate": "workflow" }), + deps.observeRpcEffect(WORKFLOW_WS_METHODS.runLane, deps.engine.runLane(input.ticketId), { + "rpc.aggregate": "workflow", + }), [WORKFLOW_WS_METHODS.getTicketDiff]: (input) => - deps.observeRpcEffect(WORKFLOW_WS_METHODS.getTicketDiff, - deps.readModel.getTicketDetail(input.ticketId).pipe( - Effect.flatMap((d) => deps.ticketDiff.getTicketDiff( - input.ticketId, /* worktree cwd */ d!.ticket /* resolve cwd */, /* baseRef */ "")), - ), { "rpc.aggregate": "workflow" }), + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getTicketDiff, + deps.ticketWorktrees + .resolveForTicket(input.ticketId) + .pipe( + Effect.flatMap(({ cwd, baseRef }) => + deps.ticketDiff.getTicketDiff(input.ticketId, cwd, baseRef), + ), + ), + { "rpc.aggregate": "workflow" }, + ), [WORKFLOW_WS_METHODS.subscribeBoard]: (input) => - deps.observeRpcStreamEffect(WORKFLOW_WS_METHODS.subscribeBoard, + deps.observeRpcStreamEffect( + WORKFLOW_WS_METHODS.subscribeBoard, Effect.gen(function* () { const board = yield* deps.readModel.getBoard(input.boardId); const tickets = yield* deps.readModel.listTickets(input.boardId); - const snapshot = { kind: "snapshot" as const, - snapshot: { board: /* shape board+lanes */ board, tickets } }; - const live = deps.boardEvents.stream(input.boardId).pipe( - Stream.map((ticket) => ({ kind: "ticket" as const, ticket }))); + const snapshot = { + kind: "snapshot" as const, + snapshot: { board: /* shape board+lanes */ board, tickets }, + }; + const live = deps.boardEvents + .stream(input.boardId) + .pipe(Stream.map((ticket) => ({ kind: "ticket" as const, ticket }))); return Stream.concat(Stream.make(snapshot), live); - }), { "rpc.aggregate": "workflow" }), + }), + { "rpc.aggregate": "workflow" }, + ), // + getBoard, getTicketDetail, registerBoardFromFile }); ``` -- [ ] **Step 2: Merge into `ws.ts`** — spread `workflowRpcHandlers({...})` into the `WsRpcGroup.of({...})` object; obtain the workflow services from the runtime (they’re provided by `WorkflowRuntimeLive`). Add `RPC_REQUIRED_SCOPE` entries: +- [ ] **Step 2: Merge into `ws.ts`** — import the workflow RPC constants/types, spread `workflowRpcHandlers({...})` into the existing `WsRpcGroup.of({...})` object inside `makeWsRpcLayer`, and use the local helper closures `observeRpcEffect`, `observeRpcStream`, and `observeRpcStreamEffect`. Add `RPC_REQUIRED_SCOPE` entries: + +`ticketWorktrees.resolveForTicket(ticketId)` can be a small read-side helper. It must resolve the ticket's current worktree path and return `baseRef: ticketBaseRef(ticketId)`; if the ticket/worktree does not exist yet, fail with `WorkflowRpcError` instead of passing placeholder strings to `TicketDiffQuery`. ```typescript [WORKFLOW_WS_METHODS.subscribeBoard, AuthWorkflowReadScope], @@ -249,12 +311,12 @@ export const workflowRpcHandlers = (deps: { [WORKFLOW_WS_METHODS.registerBoardFromFile, AuthWorkflowOperateScope], ``` -- [ ] **Step 3: Wire `WorkflowRuntimeLive` into the server runtime** and run recovery at startup (mirror the existing reactor startup in `serverRuntimeStartup.ts`): provide the layer, then `yield* (yield* WorkflowRecovery).recover()` once during boot. +- [ ] **Step 3: Wire `WorkflowRuntimeLive` into the server runtime** in the `apps/server/src/server.ts` layer graph (this is where runtime layers are composed). Run recovery once during boot from the existing startup path by resolving `WorkflowRecovery` from the runtime and executing `recover()` after migrations and workflow services are available. - [ ] **Step 4: Typecheck + a thin server RPC test** (if the repo has ws handler tests, mirror them; otherwise rely on the web e2e in Task 8). Commit. ```bash -git add apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts apps/server/src/ws.ts apps/server/src/serverRuntimeStartup.ts +git add apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts apps/server/src/ws.ts apps/server/src/server.ts apps/server/src/serverRuntimeStartup.ts git commit -m "feat(workflow): wire workflow.* RPC handlers + runtime + recovery at startup" ``` @@ -262,7 +324,7 @@ git commit -m "feat(workflow): wire workflow.* RPC handlers + runtime + recovery ## Task 5: Web — board state slice + subscription -**Files:** `apps/web/src/workflow/boardState.ts` + `boardRpc.ts` + `boardState.test.ts`. +**Files:** `packages/client-runtime/src/wsRpcClient.ts`, `packages/contracts/src/ipc.ts`, `apps/web/src/environmentApi.ts`, `apps/web/src/workflow/boardState.ts`, `boardRpc.ts`, `boardState.test.ts`. - [ ] **Step 1: Failing test** (`vite-plus/test`) @@ -275,17 +337,38 @@ describe("boardState", () => { it("applies a snapshot then a ticket delta", () => { let s = applyBoardStreamItem(emptyBoardState, { kind: "snapshot", - snapshot: { board: { boardId: "b-1", name: "Delivery", - lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }, - { key: "done", name: "Done", entry: "manual", terminal: true }] }, - tickets: [{ ticketId: "t-1", boardId: "b-1", title: "X", - currentLaneKey: "backlog", status: "idle" }] }, + snapshot: { + board: { + boardId: "b-1", + name: "Delivery", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }, + tickets: [ + { + ticketId: "t-1", + boardId: "b-1", + title: "X", + currentLaneKey: "backlog", + status: "idle", + }, + ], + }, } as never); expect(s.ticketIds).toEqual(["t-1"]); - s = applyBoardStreamItem(s, { kind: "ticket", - ticket: { ticketId: "t-1", boardId: "b-1", title: "X", - currentLaneKey: "done", status: "done" } } as never); + s = applyBoardStreamItem(s, { + kind: "ticket", + ticket: { + ticketId: "t-1", + boardId: "b-1", + title: "X", + currentLaneKey: "done", + status: "done", + }, + } as never); expect(s.ticketById["t-1"].currentLaneKey).toBe("done"); }); }); @@ -304,23 +387,41 @@ export interface BoardState { readonly boardName: string; readonly lanes: ReadonlyArray<{ key: string; name: string; entry: string; terminal?: boolean }>; readonly ticketIds: ReadonlyArray; - readonly ticketById: Record; + readonly ticketById: Record< + string, + { + ticketId: string; + title: string; + currentLaneKey: string; + status: string; + } + >; } export const emptyBoardState: BoardState = { - boardId: null, boardName: "", lanes: [], ticketIds: [], ticketById: {}, + boardId: null, + boardName: "", + lanes: [], + ticketIds: [], + ticketById: {}, }; export const applyBoardStreamItem = (state: BoardState, item: BoardStreamItem): BoardState => { if (item.kind === "snapshot") { const ticketById: BoardState["ticketById"] = {}; - for (const t of item.snapshot.tickets) ticketById[t.ticketId] = { - ticketId: t.ticketId, title: t.title, currentLaneKey: t.currentLaneKey, status: t.status }; + for (const t of item.snapshot.tickets) + ticketById[t.ticketId] = { + ticketId: t.ticketId, + title: t.title, + currentLaneKey: t.currentLaneKey, + status: t.status, + }; return { - boardId: item.snapshot.board.boardId, boardName: item.snapshot.board.name, + boardId: item.snapshot.board.boardId, + boardName: item.snapshot.board.name, lanes: item.snapshot.board.lanes.map((l) => ({ ...l })), - ticketIds: item.snapshot.tickets.map((t) => t.ticketId), ticketById, + ticketIds: item.snapshot.tickets.map((t) => t.ticketId), + ticketById, }; } // ticket delta @@ -329,36 +430,62 @@ export const applyBoardStreamItem = (state: BoardState, item: BoardStreamItem): return { ...state, ticketIds: exists ? state.ticketIds : [...state.ticketIds, t.ticketId], - ticketById: { ...state.ticketById, [t.ticketId]: { - ticketId: t.ticketId, title: t.title, currentLaneKey: t.currentLaneKey, status: t.status } }, + ticketById: { + ...state.ticketById, + [t.ticketId]: { + ticketId: t.ticketId, + title: t.title, + currentLaneKey: t.currentLaneKey, + status: t.status, + }, + }, }; }; ``` Add a zustand slice (mirror `apps/web/src/store.ts`) holding `BoardState` per board and an action `applyBoardStreamItem`. -- [ ] **Step 4: Subscription wiring** in `boardRpc.ts` — mirror `environments/runtime/service.ts`: +- [ ] **Step 4: Client runtime + EnvironmentApi wiring** — add a `workflow` namespace to `WsRpcClient` and `createWsRpcClient` in `packages/client-runtime/src/wsRpcClient.ts`, using the flat protocol client methods keyed by `WORKFLOW_WS_METHODS`. Add matching workflow methods to `EnvironmentApi` in `packages/contracts/src/ipc.ts`, then map them in `apps/web/src/environmentApi.ts`. + +Example wrapper shape: + +```typescript +workflow: { + getBoard: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.getBoard](input)), + subscribeBoard: (input, listener, options) => + transport.subscribe( + (client) => client[WORKFLOW_WS_METHODS.subscribeBoard](input), + listener, + subscriptionOptions(options, WORKFLOW_WS_METHODS.subscribeBoard), + ), + createTicket: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.createTicket](input)), + moveTicket: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.moveTicket](input)), + runLane: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.runLane](input)), + resolveApproval: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.resolveApproval](input)), + getTicketDetail: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.getTicketDetail](input)), + getTicketDiff: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.getTicketDiff](input)), +} +``` + +- [ ] **Step 5: Subscription wiring** in `boardRpc.ts` — use the high-level workflow namespace exposed by `EnvironmentApi`/`WsRpcClient`: ```typescript // apps/web/src/workflow/boardRpc.ts (shape) -import { WORKFLOW_WS_METHODS } from "@t3tools/contracts"; -// connection.client.workflow.subscribeBoard({ boardId }, (item) => store.applyBoardStreamItem(item)) +// api.workflow.subscribeBoard({ boardId }, (item) => store.applyBoardStreamItem(item)) // and request helpers: -export const createTicket = (rpc, input) => rpc.request((c) => c.workflow.createTicket(input)); -export const moveTicket = (rpc, ticketId, toLane) => - rpc.request((c) => c.workflow.moveTicket({ ticketId, toLane })); -export const resolveApproval = (rpc, stepRunId, approved) => - rpc.request((c) => c.workflow.resolveApproval({ stepRunId, approved })); -export const getTicketDiff = (rpc, ticketId) => - rpc.request((c) => c.workflow.getTicketDiff({ ticketId })); +export const createTicket = (api, input) => api.workflow.createTicket(input); +export const moveTicket = (api, ticketId, toLane) => api.workflow.moveTicket({ ticketId, toLane }); +export const resolveApproval = (api, stepRunId, approved) => + api.workflow.resolveApproval({ stepRunId, approved }); +export const getTicketDiff = (api, ticketId) => api.workflow.getTicketDiff({ ticketId }); ``` -> Confirm the generated client exposes a `workflow` namespace (it’s derived from the `Rpc.make` group registration; ensure the workflow RPCs are added to the same client group as `orchestration`). +> The protocol client is flat (`client[methodName](input)`). The app-level `client.workflow.*` namespace is not generated automatically; add it manually in `packages/client-runtime/src/wsRpcClient.ts` and expose it through `EnvironmentApi`. -- [ ] **Step 5: Run → PASS. Commit.** +- [ ] **Step 6: Run → PASS. Commit.** ```bash -git add apps/web/src/workflow/boardState.ts apps/web/src/workflow/boardRpc.ts apps/web/src/workflow/boardState.test.ts apps/web/src/store.ts +git add packages/client-runtime/src/wsRpcClient.ts packages/contracts/src/ipc.ts apps/web/src/environmentApi.ts apps/web/src/workflow/boardState.ts apps/web/src/workflow/boardRpc.ts apps/web/src/workflow/boardState.test.ts apps/web/src/store.ts git commit -m "feat(web): board state slice + subscription wiring" ``` @@ -375,23 +502,40 @@ git commit -m "feat(web): board state slice + subscription wiring" import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -export function TicketCard({ ticket, onOpen }: { +export function TicketCard({ + ticket, + onOpen, +}: { ticket: { ticketId: string; title: string; status: string }; onOpen: (id: string) => void; }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id: ticket.ticketId }); - const style = { transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.5 : 1 }; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: ticket.ticketId, + }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; const badge = - ticket.status === "waiting_on_user" ? "⏸ waiting on you" : - ticket.status === "running" ? "running" : - ticket.status === "blocked" || ticket.status === "failed" ? "⚠ blocked" : - ticket.status === "done" ? "✓ done" : null; + ticket.status === "waiting_on_user" + ? "⏸ waiting on you" + : ticket.status === "running" + ? "running" + : ticket.status === "blocked" || ticket.status === "failed" + ? "⚠ blocked" + : ticket.status === "done" + ? "✓ done" + : null; return ( -
onOpen(ticket.ticketId)} - className="cursor-pointer rounded-md border border-border bg-card p-2 text-sm hover:bg-accent/40"> + className="cursor-pointer rounded-md border border-border bg-card p-2 text-sm hover:bg-accent/40" + >
{ticket.title}
{badge &&
{badge}
}
@@ -405,19 +549,34 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" import { useDroppable } from "@dnd-kit/core"; import { TicketCard } from "./TicketCard.tsx"; -export function LaneColumn({ lane, tickets, onOpen }: { +export function LaneColumn({ + lane, + tickets, + onOpen, +}: { lane: { key: string; name: string; entry: string }; tickets: ReadonlyArray<{ ticketId: string; title: string; status: string }>; onOpen: (id: string) => void; }) { const { setNodeRef } = useDroppable({ id: `lane:${lane.key}` }); return ( -
+
- {lane.name}{lane.entry} · {tickets.length} + {lane.name} + + {lane.entry} · {tickets.length} +
- t.ticketId)} strategy={verticalListSortingStrategy}> - {tickets.map((t) => )} + t.ticketId)} + strategy={verticalListSortingStrategy} + > + {tickets.map((t) => ( + + ))}
); @@ -426,13 +585,29 @@ export function LaneColumn({ lane, tickets, onOpen }: { ```tsx // apps/web/src/components/board/BoardView.tsx -import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors, closestCorners } from "@dnd-kit/core"; +import { + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, + closestCorners, +} from "@dnd-kit/core"; import { LaneColumn } from "./LaneColumn.tsx"; -export function BoardView({ state, onMove, onOpen }: { - state: { lanes: ReadonlyArray<{ key: string; name: string; entry: string }>; +export function BoardView({ + state, + onMove, + onOpen, +}: { + state: { + lanes: ReadonlyArray<{ key: string; name: string; entry: string }>; ticketIds: ReadonlyArray; - ticketById: Record }; + ticketById: Record< + string, + { ticketId: string; title: string; currentLaneKey: string; status: string } + >; + }; onMove: (ticketId: string, toLane: string) => void; onOpen: (id: string) => void; }) { @@ -440,14 +615,26 @@ export function BoardView({ state, onMove, onOpen }: { const onDragEnd = (e: DragEndEvent) => { const ticketId = String(e.active.id); const overId = e.over ? String(e.over.id) : null; - if (overId?.startsWith("lane:")) onMove(ticketId, overId.slice("lane:".length)); + const toLane = overId?.startsWith("lane:") + ? overId.slice("lane:".length) + : overId + ? state.ticketById[overId]?.currentLaneKey + : null; + const currentLane = state.ticketById[ticketId]?.currentLaneKey; + if (toLane && toLane !== currentLane) onMove(ticketId, toLane); }; return (
{state.lanes.map((lane) => ( - state.ticketById[id]).filter((t) => t.currentLaneKey === lane.key)} /> + state.ticketById[id]) + .filter((t) => t.currentLaneKey === lane.key)} + /> ))}
@@ -455,7 +642,9 @@ export function BoardView({ state, onMove, onOpen }: { } ``` -- [ ] **Step 2: Route** — `routes/_chat.$environmentId.board.tsx` using `createFileRoute` (mirror an existing route); on mount, subscribe via `boardRpc`, read the board slice, render `moveTicket(rpc,id,lane)} onOpen={openDrawer}/>`. +- [ ] **Step 2: Route** — `routes/_chat.$environmentId.board.tsx` using `createFileRoute` (mirror an existing route); on mount, read `EnvironmentApi` for the environment, subscribe via `boardRpc`, read the board slice, render `moveTicket(api,id,lane)} onOpen={openDrawer}/>`. + +> dnd-kit often reports the card under the pointer as `over.id`, not the lane droppable. Resolve both `lane:` ids and card ids back to a target lane; otherwise drops over cards become no-ops. - [ ] **Step 3: Manual verify** — start the app, open the board route, confirm lanes/cards render and dragging a card to another lane calls `moveTicket` (use the Argent/iOS tooling or a browser per your workflow; or a `vite-plus` browser test if available). @@ -481,10 +670,21 @@ git commit -m "feat(web): board view with dnd-kit lanes and ticket cards" ```tsx // apps/web/src/components/board/TicketDrawer.tsx (shape) -export function TicketDrawer({ detail, onApprove, onRunLane }: { - detail: { ticket: { title: string; currentLaneKey: string; status: string }; - steps: ReadonlyArray<{ stepRunId: string; stepKey: string; stepType: string; - status: string; waitingReason: string | null }> }; +export function TicketDrawer({ + detail, + onApprove, + onRunLane, +}: { + detail: { + ticket: { title: string; currentLaneKey: string; status: string }; + steps: ReadonlyArray<{ + stepRunId: string; + stepKey: string; + stepType: string; + status: string; + waitingReason: string | null; + }>; + }; onApprove: (stepRunId: string, approved: boolean) => void; onRunLane: () => void; }) { @@ -494,13 +694,24 @@ export function TicketDrawer({ detail, onApprove, onRunLane }: {
    {detail.steps.map((s) => (
  1. - {s.stepKey} · {s.stepType} + + {s.stepKey} · {s.stepType} + {s.status} {s.status === "awaiting_user" && ( - - + + )}
  2. @@ -508,7 +719,9 @@ export function TicketDrawer({ detail, onApprove, onRunLane }: {
{/* // reuse existing chat activity component */} {/* */} - + ); } @@ -553,7 +766,7 @@ git commit -m "feat(web): new-ticket affordance + sample board file + e2e smoke" ## Notes for the implementer - **Reuse existing UI building blocks:** the drawer should render the active step's thread with the **same** component the chat view uses (the StepRun carries a `threadId`), and the diff with the **same** `@pierre/diffs`-based viewer used for checkpoints. Don't build new renderers. -- **Client RPC namespace:** ensure the `workflow.*` `Rpc.make` definitions are registered into the same client group as `orchestration.*` so the generated client exposes `client.workflow.*`. Confirm the registration site in contracts/client-runtime. +- **Client RPC namespace:** adding workflow `Rpc.make` definitions to `WsRpcGroup` updates the flat protocol client only. Also extend `WsRpcClient`, `createWsRpcClient`, `EnvironmentApi`, and `apps/web/src/environmentApi.ts` so app code can call `client.workflow.*`/`api.workflow.*`. - **Diff cwd/baseRef:** `getTicketDiff` needs the ticket's worktree path and `baselineRef`; resolve the worktree cwd from `worktreeRef` (the VCS layer maps ref→path) and read `baseRef` as `ticketBaseRef(ticketId)`. - **Auth scopes:** add `AuthWorkflowReadScope`/`AuthWorkflowOperateScope` to the same scope registry orchestration uses, and to any default role grants so the UI can call them. - **Abort:** v1 defers `pause`; `abort` is defined in the spec (§8) but if not yet implemented end-to-end, render it disabled rather than wiring a partial path. diff --git a/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md b/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md index ec3dc10e96f..fa8d1e19b49 100644 --- a/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md +++ b/docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md @@ -28,14 +28,14 @@ Narrow surface, deep correctness. v1 ships a small board that is genuinely safe ## 3. Reused t3code primitives — and where they need new seams -| Need | Existing primitive | Gap to build in v1 | -| --- | --- | --- | -| Run an agent on a turn | Orchestration engine (event-sourced threads/turns) + ACP provider drivers (Claude, Codex, Cursor, OpenCode, Grok) via `OrchestrationEngine.dispatch` (`thread.create`, `thread.turn.start`) | A **durable provider-dispatch path** — see §7.8. `ProviderCommandReactor` consumes a *hot* domain-event stream with no replay, so a crash between command-accept and provider-turn-send is not self-healing. | -| Per-ticket repo copy | VCS worktree layer (`vcs.createWorktree`/`removeWorktree`, `GitWorkflowService`, `GitManager`) | A **`WorktreeLeaseService`** (§7.1) — no fencing exists today; git ops mutate by `cwd`. And setup-script invocation is **not** part of `createWorktree` (§7.2). | -| Agent asked a question / needs approval | Pending-approval projection (`projection_pending_approvals`) for approvals; user *questions* are activity rows | **Durable workflow approval gates** distinct from **volatile provider questions/approvals**, which do not survive restart (§7.3, §7.9). | -| Per-turn diffs | Checkpoint system (`CheckpointStore`, `CheckpointDiffQuery`), refs keyed `refs/t3/checkpoints//turn/` | **Ticket-level baseline ref + accumulated diff** query — net-new; thread-turn diffs only give one step's delta (§7.10). | -| Real-time UI | WebSocket RPC subscriptions over `orchestrationEngine.streamDomainEvents` (per `ws.ts`); React 19 + TanStack Router + Zustand/Effect atoms + dnd-kit | A parallel **`workflow.streamBoardEvents`** subscription for board projections. | -| Provider routing | `ProviderInstanceId` + `ModelSelection` | None — reused as-is. | +| Need | Existing primitive | Gap to build in v1 | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Run an agent on a turn | Orchestration engine (event-sourced threads/turns) + ACP provider drivers (Claude, Codex, Cursor, OpenCode, Grok) via `OrchestrationEngine.dispatch` (`thread.create`, `thread.turn.start`) | A **durable provider-dispatch path** — see §7.8. `ProviderCommandReactor` consumes a _hot_ domain-event stream with no replay, so a crash between command-accept and provider-turn-send is not self-healing. | +| Per-ticket repo copy | VCS worktree layer (`vcs.createWorktree`/`removeWorktree`, `GitWorkflowService`, `GitManager`) | A **`WorktreeLeaseService`** (§7.1) — no fencing exists today; git ops mutate by `cwd`. And setup-script invocation is **not** part of `createWorktree` (§7.2). | +| Agent asked a question / needs approval | Pending-approval projection (`projection_pending_approvals`) for approvals; user _questions_ are activity rows | **Durable workflow approval gates** distinct from **volatile provider questions/approvals**, which do not survive restart (§7.3, §7.9). | +| Per-turn diffs | Checkpoint system (`CheckpointStore`, `CheckpointDiffQuery`), refs keyed `refs/t3/checkpoints//turn/` | **Ticket-level baseline ref + accumulated diff** query — net-new; thread-turn diffs only give one step's delta (§7.10). | +| Real-time UI | WebSocket RPC subscriptions over `orchestrationEngine.streamDomainEvents` (per `ws.ts`); React 19 + TanStack Router + Zustand/Effect atoms + dnd-kit | A parallel **`workflow.streamBoardEvents`** subscription for board projections. | +| Provider routing | `ProviderInstanceId` + `ModelSelection` | None — reused as-is. | **Critical reuse constraint:** the provider command reactor restricts switching provider instances inside an active session. Each step therefore runs as its **own thread** (one thread per step), all sharing the ticket's worktree as cwd. Do **not** reuse a single thread across steps with different providers. @@ -43,7 +43,7 @@ Narrow surface, deep correctness. v1 ships a small board that is genuinely safe **In scope (v1):** manual tickets; one board per project; JSON workflow file (loader + Effect Schema validation + config linter); lanes with `auto`/`manual` entry; `agent` and `approval` steps; lane-level routing; manual drag (with lane-entry tokens); worktree-per-ticket with a single-writer lease; ticket baseline + per-step checkpoints and accumulated ticket diff; per-run workflow-version snapshots; durable saga (provider-dispatch outbox, deterministic IDs, crash recovery); board UI + ticket drill-in + waiting-on-user with inline answering; configurable max concurrent running tickets per board. -**Out of scope (deferred):** script steps, conditional predicates, per-step routing, WIP *enforcement* (v1 displays counts only), external events, PM-tool sync, multiple boards per project, DAG lanes, the visual workflow editor, and in-flight `pause` (v1 supports `abort` only — see §8). +**Out of scope (deferred):** script steps, conditional predicates, per-step routing, WIP _enforcement_ (v1 displays counts only), external events, PM-tool sync, multiple boards per project, DAG lanes, the visual workflow editor, and in-flight `pause` (v1 supports `abort` only — see §8). ## 5. Data model @@ -81,24 +81,35 @@ JSON with a `$schema`, validated by an Effect Schema, at `.t3/boards/.json "settings": { "maxConcurrentTickets": 3 }, "lanes": [ { "key": "backlog", "name": "Backlog", "entry": "manual" }, - { "key": "implement", "name": "Implement", "entry": "auto", + { + "key": "implement", + "name": "Implement", + "entry": "auto", "pipeline": [ - { "key": "code", "type": "agent", + { + "key": "code", + "type": "agent", "agent": { "instance": "claude_main", "model": "sonnet" }, - "instruction": { "file": "prompts/implement.md" } }, - { "key": "review", "type": "agent", + "instruction": { "file": "prompts/implement.md" }, + }, + { + "key": "review", + "type": "agent", "agent": { "instance": "codex_main", "model": "gpt-5.4" }, - "instruction": "Adversarially review the diff; list blocking issues." } + "instruction": "Adversarially review the diff; list blocking issues.", + }, ], - "on": { "success": "owner_review", "failure": "needs_attention" } }, + "on": { "success": "owner_review", "failure": "needs_attention" }, + }, { "key": "owner_review", "name": "Owner Review", "entry": "manual" }, { "key": "needs_attention", "name": "Needs Attention", "entry": "manual" }, - { "key": "done", "name": "Done", "entry": "manual", "terminal": true } - ] + { "key": "done", "name": "Done", "entry": "manual", "terminal": true }, + ], } ``` ### Config linter (load-time) + Rejects: duplicate lane/step keys; routing targets referencing missing lanes; `auto`-lane cycles with no human/terminal break; unreachable terminal lanes; unknown/unconfigured provider instances; missing instruction files. **Diagnostics:** the loader parses with a **location-aware JSONC parser** (not bare Effect Schema decode, which loses source positions) so lint errors carry file/line/column. Lint errors block board activation and surface in the UI. ## 7. Engine behavior @@ -113,7 +124,7 @@ A persistent fencing lease, one row per ticket worktree in `worktree_lease`: `{ - **Acquire/release:** the engine acquires before an agent step's provider dispatch and releases on terminal step state. Approval steps hold no lease. - **TTL/recovery:** leases have a TTL; on restart the engine reconciles — a lease whose owning StepRun is terminal or superseded is released; an still-running owner re-validates. - **Manual drag / abort:** supersede the current run, which invalidates its fence token so any late completion cannot mutate the tree. -- **Scope (v1):** because v1 runs steps strictly sequentially per ticket and never lets the user edit the ticket worktree concurrently with a running step, the lease is a correctness guard against *stale/superseded* writers, not against intentional parallel writers (that arrives with v2 scripts / v3 DAGs). +- **Scope (v1):** because v1 runs steps strictly sequentially per ticket and never lets the user edit the ticket worktree concurrently with a running step, the lease is a correctness guard against _stale/superseded_ writers, not against intentional parallel writers (that arrives with v2 scripts / v3 DAGs). ### 7.2 Agent step execution @@ -163,13 +174,13 @@ A board-level `maxConcurrentTickets` caps tickets in `running` state simultaneou The hazard, precisely: `OrchestrationEngine.dispatch` persists and **dedupes the command**, but the provider side-effect that actually starts the agent runs in `ProviderCommandReactor`, which consumes a **hot, non-replayed** domain-event stream. Therefore neither "command accepted" nor a naïve re-dispatch (which returns the stored receipt **without re-emitting the hot event**) proves the provider turn ran. Two facts must be kept distinct: -- **Command intake** — `thread.turn-start-requested` is persisted the moment the command is accepted. This is *not* evidence the agent started. -- **Provider progress** — the projected turn transitions to `running`/`completed`/`failed` only after `ProviderRuntimeIngestion` observes the provider actually doing work. This *is* the durable evidence. +- **Command intake** — `thread.turn-start-requested` is persisted the moment the command is accepted. This is _not_ evidence the agent started. +- **Provider progress** — the projected turn transitions to `running`/`completed`/`failed` only after `ProviderRuntimeIngestion` observes the provider actually doing work. This _is_ the durable evidence. v1 introduces a **provider-dispatch outbox** owned by the workflow engine, and confirms on **provider progress**, not command intake: 1. The engine writes a `ProviderDispatchRequested` row to `workflow_dispatch_outbox` with a deterministic `dispatchId` and `{ threadId, messageId, commandId, providerInstance, model, instruction }`. -2. A dedicated **dispatch worker** (durable, replayable, owned by the workflow engine — not the hot orchestration reactor) ensures the provider turn is actually running. Because the existing reactor does not replay, the worker must *own* the side-effect rather than assume a re-dispatch re-fires it. **Implementation options (decide in planning):** +2. A dedicated **dispatch worker** (durable, replayable, owned by the workflow engine — not the hot orchestration reactor) ensures the provider turn is actually running. Because the existing reactor does not replay, the worker must _own_ the side-effect rather than assume a re-dispatch re-fires it. **Implementation options (decide in planning):** - **(a) Workflow-driven provider start (preferred):** the worker drives `ProviderService` start/sendTurn directly — the same call the reactor makes — so retry/replay live in the workflow engine. Guarded for idempotency: before invoking, check persisted provider progress for `threadId`/turn; if it already shows `running`/terminal, skip. - **(b) Replayable orchestration outbox:** add a durable, replayable provider-command path inside `ProviderCommandReactor` so re-dispatch re-fires. Heavier (touches shared orchestration internals); only if (a) proves insufficient. 3. The worker writes `ProviderDispatchConfirmed` **only when persisted provider progress** (turn `running`/terminal from `ProviderRuntimeIngestion`) is observed — never on command intake. Until then the outbox row stays pending and retries on backoff. @@ -188,16 +199,18 @@ Existing checkpoint refs are `threadId`+turn scoped and the diff query is **ref- - **Baseline ref** — at ticket creation, capture `Ticket.baselineRef`: a real git ref/commit (`refs/t3/tickets//base`) pointing at the worktree's starting tree. (Renamed from the earlier `baselineCheckpointId`; it is a ref, not a checkpoint id.) - **Per-step refs** — around each agent step, capture `preCheckpointRef`/`postCheckpointRef` under a ticket namespace (`refs/t3/tickets//step//{pre,post}`), reusing `CheckpointStore`'s capture plumbing. - **Two distinct diff APIs:** - - *Per-step diff* = ref-to-ref `preCheckpointRef → postCheckpointRef`, served by the existing `CheckpointStore.diffCheckpoints` (ref-to-ref) — direct reuse. - - *Accumulated ticket diff* = **base-ref → live working tree** (includes uncommitted state), which `diffCheckpoints` cannot express. This needs a **new `TicketDiffQuery.diffBaseToWorktree(baselineRef, worktreeRef)`** built on the working-tree git-diff plumbing in `GitVcsDriverCore` (git diff against `baselineRef`), not on ref-to-ref checkpoint diffing. + - _Per-step diff_ = ref-to-ref `preCheckpointRef → postCheckpointRef`, served by the existing `CheckpointStore.diffCheckpoints` (ref-to-ref) — direct reuse. + - _Accumulated ticket diff_ = **base-ref → live working tree** (includes uncommitted state), which `diffCheckpoints` cannot express. This needs a **new `TicketDiffQuery.diffBaseToWorktree(baselineRef, worktreeRef)`** built on the working-tree git-diff plumbing in `GitVcsDriverCore` (git diff against `baselineRef`), not on ref-to-ref checkpoint diffing. - This is explicitly net-new server work in v1 (not pure reuse): the ticket ref namespace, the baseline capture, and `TicketDiffQuery`. ## 8. UI ### Board view + Lanes as columns showing name, `auto`/`manual`, and a WIP count (display only). Tickets as drag-able cards (dnd-kit). Card badges: agent+model chip with running spinner; `⏸ waiting on you`; `⚠ blocked`/`failed`; `✓ done`. ### Ticket drill-in + - **Step timeline:** each StepRun with agent, status, result. - **Live agent activity:** reuse existing thread rendering for the active step's thread. - **Question / approval inbox:** answer inline; answering resolves the gate (durable) or the live provider request (volatile) and resumes/​re-dispatches the step (§7.3). @@ -205,6 +218,7 @@ Lanes as columns showing name, `auto`/`manual`, and a WIP count (display only). - **Controls (v1):** `run` (start a manual lane's pipeline) and `abort`. **`abort`** is a durable transition: it supersedes the run, signals interrupt to the provider, waits for provider-terminal + checkpoint finalization, then releases the lease and marks the StepRun `failed`/ticket `blocked`. **`pause` is deferred** (no clean v1 semantics for releasing the lease mid-turn). ### Workflow file errors + Parse/lint errors (§6) surface in the board UI via the existing settings file-watcher pattern (reload/debounce). No in-app editing in v1. ## 9. Guarantees & failure modes diff --git a/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md b/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md index 9df7c4b649f..ec82bd9a9fa 100644 --- a/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md +++ b/docs/superpowers/specs/2026-06-06-workflow-boards-v2-design.md @@ -12,6 +12,7 @@ v2 adds the **configuration power** on top of v1's rigorous core: first-class sc ## 2. Scope **In scope (v2):** + 1. Script steps (first-class, durable, trust-gated). 2. Structured agent-step outputs (enabling sub-feature). 3. Conditional predicates (`when`) on transitions. @@ -26,22 +27,33 @@ v2 adds the **configuration power** on top of v1's rigorous core: first-class sc **Motivation:** a board that ships code is far more useful if pipelines can deterministically gate on tests/lint with clean pass/fail, logs, and cancellation. v1 deliberately deferred this because repo-owned shell commands are effectively local CI / arbitrary code execution. ### Schema + New step `type: "script"` with **structured** config (no free-form shell string): ```jsonc -{ "key": "tests", "type": "script", - "command": "pnpm", "args": ["test"], - "timeout": 600, "env": ["CI"], "maxOutputBytes": 1048576, - "shell": false, "workingDir": "." } +{ + "key": "tests", + "type": "script", + "command": "pnpm", + "args": ["test"], + "timeout": 600, + "env": ["CI"], + "maxOutputBytes": 1048576, + "shell": false, + "workingDir": ".", +} ``` ### Durable ScriptRun entity + A first-class `ScriptRun` with its own event stream: `ScriptRunStarted`, periodic `ScriptRunOutputChunk` (capped, persisted), `ScriptRunExited { exitCode }`. Output is live-streamed to the UI via the push bus, cancellable (kills the process group), and timeout-enforced. This replaces v1's reliance on a fire-and-forget `ProcessRunner.run`. ### Trust model + A project must be **explicitly trusted** to run workflow scripts before any script step executes. Untrusted → the step parks as `blocked` with a "grant trust" prompt. Trust is keyed to the workflow file's content hash, so edits re-prompt. Trust state is persisted per project. ### Integration with v1 + - `StepRun.execRef` gains `{ type: "script", scriptRunId }`. - The script holds the ticket's worktree **lease** while running (it is a writer). - Routing: exit 0 = success, non-zero = failure; `exitCode` and an output tail are exposed to routing and to predicates (§5). @@ -59,19 +71,27 @@ Agent steps may emit a defined, schema-validated output object alongside their d Transitions guarded by a **restricted, safe expression DSL** (not arbitrary JS) over a typed evaluation context. ### Context + - Ticket fields: `label`, `priority`, `status`. - Last pipeline result: `pipeline.result`. - Structured step outputs: `steps..` (from §4), and `steps..exitCode` (from §3). ### Example + ```jsonc -{ "key": "review_gate", "name": "Review", "entry": "auto", - "pipeline": [ /* … */ ], +{ + "key": "review_gate", + "name": "Review", + "entry": "auto", + "pipeline": [ + /* … */ + ], "transitions": [ { "when": "steps.review.verdict == \"block\"", "to": "needs_attention" }, - { "when": "label == \"approved\"", "to": "done" } + { "when": "label == \"approved\"", "to": "done" }, ], - "on": { "success": "owner_review" } } + "on": { "success": "owner_review" }, +} ``` - Schema-validated and linted at load: unknown context fields rejected; type mismatches rejected. @@ -83,6 +103,7 @@ Transitions guarded by a **restricted, safe expression DSL** (not arbitrary JS) A step may declare its own `on: { success, failure, blocked }` that **short-circuits** the rest of the pipeline and routes the ticket immediately (e.g. review → "block" → jump straight to Needs Attention, skipping later steps). **Precedence (explicit, linted):** + 1. Step-level `on` (if the step short-circuits). 2. Conditional `transitions` (`when`) evaluated at pipeline completion. 3. Lane-level `on`. @@ -93,6 +114,7 @@ The linter validates that step-level and lane-level routes don't reference missi ## 7. WIP-limit enforcement v1 displays WIP counts; v2 enforces them. A lane with `wipLimit: N`: + - **Block mode:** manual drags into a full lane are rejected with UI feedback; auto-routing holds the ticket in the prior lane (status reflects "waiting for WIP slot") until a slot frees. - **Queue mode:** incoming tickets queue in order and admit as slots free. From dd9f3af71f86ecc25b9da7d055ae8cd75b1a3015 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 01:51:41 -0400 Subject: [PATCH 008/295] feat(workflow): add branded ids and keys for workflow boards Constraint: M1 Task 1 required strict red/green TDD before implementation. Confidence: high Scope-risk: narrow Tested: pnpm --filter @t3tools/contracts test -- workflow.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- packages/contracts/src/workflow.test.ts | 24 ++++++++++++++++++++ packages/contracts/src/workflow.ts | 30 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 packages/contracts/src/workflow.test.ts create mode 100644 packages/contracts/src/workflow.ts diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts new file mode 100644 index 00000000000..ec9c52c2ad9 --- /dev/null +++ b/packages/contracts/src/workflow.test.ts @@ -0,0 +1,24 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { BoardId, LaneEntryToken, TicketId, WorkflowEventId } from "./workflow.ts"; + +describe("workflow ids", () => { + it("brands a board id from a non-empty string", () => { + const id = BoardId.make("board-123"); + assert.equal(id, "board-123"); + }); + + it.effect("rejects an empty ticket id", () => + Effect.gen(function* () { + const result = yield* Effect.exit(Schema.decodeUnknownEffect(TicketId)("")); + assert.strictEqual(result._tag, "Failure"); + }), + ); + + it("brands lane-entry tokens and event ids", () => { + assert.equal(LaneEntryToken.make("tok-1"), "tok-1"); + assert.equal(WorkflowEventId.make("evt-1"), "evt-1"); + }); +}); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts new file mode 100644 index 00000000000..6aad670ae46 --- /dev/null +++ b/packages/contracts/src/workflow.ts @@ -0,0 +1,30 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const makeId = (brand: Brand) => + TrimmedNonEmptyString.pipe(Schema.brand(brand)); + +export const BoardId = makeId("BoardId"); +export type BoardId = typeof BoardId.Type; + +export const TicketId = makeId("TicketId"); +export type TicketId = typeof TicketId.Type; + +export const PipelineRunId = makeId("PipelineRunId"); +export type PipelineRunId = typeof PipelineRunId.Type; + +export const StepRunId = makeId("StepRunId"); +export type StepRunId = typeof StepRunId.Type; + +export const LaneEntryToken = makeId("LaneEntryToken"); +export type LaneEntryToken = typeof LaneEntryToken.Type; + +export const WorkflowEventId = makeId("WorkflowEventId"); +export type WorkflowEventId = typeof WorkflowEventId.Type; + +export const LaneKey = TrimmedNonEmptyString.pipe(Schema.brand("LaneKey")); +export type LaneKey = typeof LaneKey.Type; + +export const StepKey = TrimmedNonEmptyString.pipe(Schema.brand("StepKey")); +export type StepKey = typeof StepKey.Type; From a18c9a2e5f8b0e0df8b532c45e3884af590b3abf Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 01:52:59 -0400 Subject: [PATCH 009/295] feat(workflow): add workflow file definition schema (lanes/steps/routing) Constraint: M1 Task 2 required schema-only contracts work after an observed failing WorkflowDefinition test. Rejected: Multi-value Schema.Literal from the plan | unsupported by the installed Effect beta. Confidence: high Scope-risk: narrow Tested: pnpm --filter @t3tools/contracts test -- workflow.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- packages/contracts/src/workflow.test.ts | 68 ++++++++++++++++++++++++- packages/contracts/src/workflow.ts | 62 ++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts index ec9c52c2ad9..cfcb77cd28b 100644 --- a/packages/contracts/src/workflow.test.ts +++ b/packages/contracts/src/workflow.test.ts @@ -2,7 +2,13 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { BoardId, LaneEntryToken, TicketId, WorkflowEventId } from "./workflow.ts"; +import { + BoardId, + LaneEntryToken, + TicketId, + WorkflowDefinition, + WorkflowEventId, +} from "./workflow.ts"; describe("workflow ids", () => { it("brands a board id from a non-empty string", () => { @@ -22,3 +28,63 @@ describe("workflow ids", () => { assert.equal(WorkflowEventId.make("evt-1"), "evt-1"); }); }); + +describe("WorkflowDefinition", () => { + const example = { + name: "Standard delivery", + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/implement.md" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.4" }, + instruction: "Review the diff.", + }, + ], + on: { success: "owner_review", failure: "needs_attention" }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "needs_attention", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }; + + it.effect("decodes a valid workflow file", () => + Effect.gen(function* () { + const decoded = yield* Schema.decodeUnknownEffect(WorkflowDefinition)(example); + assert.equal(decoded.lanes.length, 5); + assert.equal(decoded.lanes[1]?.pipeline?.length, 2); + }), + ); + + it.effect("rejects an unknown step type", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + Schema.decodeUnknownEffect(WorkflowDefinition)({ + name: "x", + lanes: [ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "s", type: "script", run: "echo hi" }], + }, + ], + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 6aad670ae46..5db270f7025 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -28,3 +28,65 @@ export type LaneKey = typeof LaneKey.Type; export const StepKey = TrimmedNonEmptyString.pipe(Schema.brand("StepKey")); export type StepKey = typeof StepKey.Type; + +export const StepInstruction = Schema.Union([ + Schema.String, + Schema.Struct({ file: TrimmedNonEmptyString }), +]); +export type StepInstruction = typeof StepInstruction.Type; + +export const AgentSelection = Schema.Struct({ + instance: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, +}); +export type AgentSelection = typeof AgentSelection.Type; + +export const AgentStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("agent"), + agent: AgentSelection, + instruction: StepInstruction, +}); + +export const ApprovalStep = Schema.Struct({ + key: StepKey, + type: Schema.Literal("approval"), + prompt: Schema.optional(Schema.String), +}); + +export const WorkflowStep = Schema.Union([AgentStep, ApprovalStep]); +export type WorkflowStep = typeof WorkflowStep.Type; + +export const LaneEntry = Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]); +export type LaneEntry = typeof LaneEntry.Type; + +export const LaneRouting = Schema.Struct({ + success: Schema.optional(LaneKey), + failure: Schema.optional(LaneKey), + blocked: Schema.optional(LaneKey), +}); +export type LaneRouting = typeof LaneRouting.Type; + +export const WorkflowLane = Schema.Struct({ + key: LaneKey, + name: TrimmedNonEmptyString, + entry: LaneEntry, + pipeline: Schema.optional(Schema.Array(WorkflowStep)), + on: Schema.optional(LaneRouting), + wipLimit: Schema.optional(Schema.Int), + color: Schema.optional(Schema.String), + terminal: Schema.optional(Schema.Boolean), +}); +export type WorkflowLane = typeof WorkflowLane.Type; + +export const WorkflowSettings = Schema.Struct({ + maxConcurrentTickets: Schema.optional(Schema.Int), +}); +export type WorkflowSettings = typeof WorkflowSettings.Type; + +export const WorkflowDefinition = Schema.Struct({ + name: TrimmedNonEmptyString, + settings: Schema.optional(WorkflowSettings), + lanes: Schema.Array(WorkflowLane), +}); +export type WorkflowDefinition = typeof WorkflowDefinition.Type; From cac3d4dfc70b54096871521168e1ddfa851cbbbc Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 01:57:25 -0400 Subject: [PATCH 010/295] feat(workflow): add pure workflow-file linter Constraint: M1 Task 3 required a pure linter with provider and instruction-file checks injected. Confidence: high Scope-risk: narrow Tested: pnpm --filter t3 test -- workflowFile.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- apps/server/src/workflow/workflowFile.test.ts | 112 +++++++++++++++ apps/server/src/workflow/workflowFile.ts | 128 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 apps/server/src/workflow/workflowFile.test.ts create mode 100644 apps/server/src/workflow/workflowFile.ts diff --git a/apps/server/src/workflow/workflowFile.test.ts b/apps/server/src/workflow/workflowFile.test.ts new file mode 100644 index 00000000000..3fd8289a1aa --- /dev/null +++ b/apps/server/src/workflow/workflowFile.test.ts @@ -0,0 +1,112 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { WorkflowDefinition } from "@t3tools/contracts"; + +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const base = (lanes: unknown): WorkflowDefinition => + ({ name: "wf", lanes }) as unknown as WorkflowDefinition; + +const ctx = { + providerInstanceExists: (id: string) => id === "claude_main", + instructionFileExists: (path: string) => path === "prompts/ok.md", +}; + +describe("lintWorkflowDefinition", () => { + it("passes a valid definition", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.deepEqual(errors, []); + }); + + it("flags duplicate lane keys", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "manual" }, + { key: "a", name: "A2", entry: "manual" }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "duplicate_lane_key")); + }); + + it("flags routing to a missing lane", () => { + const errors = lintWorkflowDefinition( + base([{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_lane_ref")); + }); + + it("flags an unknown provider instance", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "nope", model: "x" }, + instruction: "hi", + }, + ], + }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "unknown_provider_instance")); + }); + + it("flags a missing instruction file", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "prompts/missing.md" }, + }, + ], + }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_instruction_file")); + }); + + it("flags an auto-lane cycle with no human/terminal break", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "auto", on: { success: "b" } }, + { key: "b", name: "B", entry: "auto", on: { success: "a" } }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "auto_lane_cycle")); + }); +}); diff --git a/apps/server/src/workflow/workflowFile.ts b/apps/server/src/workflow/workflowFile.ts new file mode 100644 index 00000000000..8314661f40c --- /dev/null +++ b/apps/server/src/workflow/workflowFile.ts @@ -0,0 +1,128 @@ +import type { WorkflowDefinition, WorkflowLane } from "@t3tools/contracts"; + +export type LintCode = + | "duplicate_lane_key" + | "duplicate_step_key" + | "missing_lane_ref" + | "unknown_provider_instance" + | "missing_instruction_file" + | "auto_lane_cycle" + | "unreachable_terminal"; + +export interface LintError { + readonly code: LintCode; + readonly message: string; + readonly laneKey?: string; + readonly stepKey?: string; +} + +export interface LintContext { + readonly providerInstanceExists: (instanceId: string) => boolean; + readonly instructionFileExists: (repoRelativePath: string) => boolean; +} + +const routingTargets = (lane: WorkflowLane): ReadonlyArray => { + const on = lane.on; + if (!on) { + return []; + } + return [on.success, on.failure, on.blocked].filter( + (target): target is string => typeof target === "string", + ); +}; + +export const lintWorkflowDefinition = ( + def: WorkflowDefinition, + ctx: LintContext, +): ReadonlyArray => { + const errors: LintError[] = []; + const laneKeys = new Set(); + const allKeys = def.lanes.map((lane) => lane.key as string); + + for (const lane of def.lanes) { + const laneKey = lane.key as string; + if (laneKeys.has(laneKey)) { + errors.push({ + code: "duplicate_lane_key", + laneKey, + message: `Duplicate lane key "${laneKey}"`, + }); + } + laneKeys.add(laneKey); + + const stepKeys = new Set(); + for (const step of lane.pipeline ?? []) { + const stepKey = step.key as string; + if (stepKeys.has(stepKey)) { + errors.push({ + code: "duplicate_step_key", + laneKey, + stepKey, + message: `Duplicate step key "${stepKey}" in lane "${laneKey}"`, + }); + } + stepKeys.add(stepKey); + + if (step.type !== "agent") { + continue; + } + + if (!ctx.providerInstanceExists(step.agent.instance)) { + errors.push({ + code: "unknown_provider_instance", + laneKey, + stepKey, + message: `Unknown provider instance "${step.agent.instance}"`, + }); + } + + if ( + typeof step.instruction === "object" && + !ctx.instructionFileExists(step.instruction.file) + ) { + errors.push({ + code: "missing_instruction_file", + laneKey, + stepKey, + message: `Instruction file not found: "${step.instruction.file}"`, + }); + } + } + + for (const target of routingTargets(lane)) { + if (!allKeys.includes(target)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" routes to missing lane "${target}"`, + }); + } + } + } + + const byKey = new Map(def.lanes.map((lane) => [lane.key as string, lane] as const)); + for (const lane of def.lanes) { + if (lane.entry !== "auto") { + continue; + } + + const seen = new Set(); + let cursor: WorkflowLane | undefined = lane; + while (cursor && cursor.entry === "auto" && !cursor.terminal) { + const cursorKey = cursor.key as string; + if (seen.has(cursorKey)) { + errors.push({ + code: "auto_lane_cycle", + laneKey: lane.key as string, + message: `Auto-lane cycle detected starting at "${lane.key}"`, + }); + break; + } + seen.add(cursorKey); + const next = cursor.on?.success; + cursor = next ? byKey.get(next) : undefined; + } + } + + return errors; +}; From 54216ccff1b33b5666d3c8cad2f2d024e1e814ca Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 01:58:03 -0400 Subject: [PATCH 011/295] feat(workflow): add workflow event union Constraint: M1 Task 4 required event schemas after an observed failing WorkflowEvent test. Rejected: Multi-value Schema.Literal from the plan | unsupported by the installed Effect beta. Confidence: high Scope-risk: narrow Tested: pnpm --filter @t3tools/contracts test -- workflow.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- packages/contracts/src/workflow.test.ts | 33 +++++++ packages/contracts/src/workflow.ts | 119 +++++++++++++++++++++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts index cfcb77cd28b..5cfa2fe19ba 100644 --- a/packages/contracts/src/workflow.test.ts +++ b/packages/contracts/src/workflow.test.ts @@ -7,6 +7,7 @@ import { LaneEntryToken, TicketId, WorkflowDefinition, + WorkflowEvent, WorkflowEventId, } from "./workflow.ts"; @@ -88,3 +89,35 @@ describe("WorkflowDefinition", () => { }), ); }); + +describe("WorkflowEvent", () => { + const ticketCreated = { + type: "TicketCreated", + eventId: "evt-1", + ticketId: "t-1", + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z", + payload: { boardId: "b-1", title: "Add export", laneKey: "backlog" }, + }; + + it.effect("decodes a TicketCreated event", () => + Effect.gen(function* () { + const event = yield* Schema.decodeUnknownEffect(WorkflowEvent)(ticketCreated); + assert.equal(event.type, "TicketCreated"); + }), + ); + + it.effect("decodes a TicketMovedToLane event", () => + Effect.gen(function* () { + const event = yield* Schema.decodeUnknownEffect(WorkflowEvent)({ + type: "TicketMovedToLane", + eventId: "evt-2", + ticketId: "t-1", + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z", + payload: { toLane: "implement", laneEntryToken: "tok-1", reason: "manual" }, + }); + assert.equal(event.type, "TicketMovedToLane"); + }), + ); +}); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 5db270f7025..3739ffd9694 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; const makeId = (brand: Brand) => TrimmedNonEmptyString.pipe(Schema.brand(brand)); @@ -90,3 +90,120 @@ export const WorkflowDefinition = Schema.Struct({ lanes: Schema.Array(WorkflowLane), }); export type WorkflowDefinition = typeof WorkflowDefinition.Type; + +const EventBase = { + eventId: WorkflowEventId, + ticketId: TicketId, + streamVersion: Schema.Int, + occurredAt: IsoDateTime, +}; + +export const TicketStatus = Schema.Union([ + Schema.Literal("idle"), + Schema.Literal("running"), + Schema.Literal("waiting_on_user"), + Schema.Literal("blocked"), + Schema.Literal("done"), + Schema.Literal("failed"), +]); +export type TicketStatus = typeof TicketStatus.Type; + +export const StepRunStatus = Schema.Union([ + Schema.Literal("pending"), + Schema.Literal("dispatch_requested"), + Schema.Literal("running"), + Schema.Literal("awaiting_user"), + Schema.Literal("completed"), + Schema.Literal("failed"), + Schema.Literal("superseded"), +]); +export type StepRunStatus = typeof StepRunStatus.Type; + +export const WorkflowEvent = Schema.Union([ + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketCreated"), + payload: Schema.Struct({ + boardId: BoardId, + title: TrimmedNonEmptyString, + laneKey: LaneKey, + description: Schema.optional(Schema.String), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketMovedToLane"), + payload: Schema.Struct({ + toLane: LaneKey, + laneEntryToken: LaneEntryToken, + reason: Schema.Union([ + Schema.Literal("manual"), + Schema.Literal("routed"), + Schema.Literal("initial"), + ]), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketBlocked"), + payload: Schema.Struct({ reason: Schema.String }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("PipelineStarted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + laneKey: LaneKey, + laneEntryToken: LaneEntryToken, + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("PipelineCompleted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + result: Schema.Union([ + Schema.Literal("success"), + Schema.Literal("failure"), + Schema.Literal("blocked"), + Schema.Literal("superseded"), + ]), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepStarted"), + payload: Schema.Struct({ + pipelineRunId: PipelineRunId, + stepRunId: StepRunId, + stepKey: StepKey, + stepType: Schema.Union([Schema.Literal("agent"), Schema.Literal("approval")]), + }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepAwaitingUser"), + payload: Schema.Struct({ stepRunId: StepRunId, waitingReason: Schema.String }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepUserResolved"), + payload: Schema.Struct({ stepRunId: StepRunId }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepCompleted"), + payload: Schema.Struct({ stepRunId: StepRunId }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepFailed"), + payload: Schema.Struct({ stepRunId: StepRunId, error: Schema.String }), + }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("TicketRouted"), + payload: Schema.Struct({ fromLane: LaneKey, toLane: LaneKey }), + }), +]); +export type WorkflowEvent = typeof WorkflowEvent.Type; From 9d5ed34f4d1b65238f8583b7c5195f486a86d570 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 01:59:05 -0400 Subject: [PATCH 012/295] feat(workflow): add workflow_events and projection tables migration Constraint: M1 Task 5 required assigning the next migration ID from the real registry. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEventStore.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/033_WorkflowEvents.ts | 85 +++++++++++++++++++ .../Layers/WorkflowEventStore.test.ts | 28 ++++++ 3 files changed, 115 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/033_WorkflowEvents.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEventStore.test.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..00548b01b3d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_WorkflowEvents.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "WorkflowEvents", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_WorkflowEvents.ts b/apps/server/src/persistence/Migrations/033_WorkflowEvents.ts new file mode 100644 index 00000000000..8bb5c766ff3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_WorkflowEvents.ts @@ -0,0 +1,85 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + ticket_id TEXT NOT NULL, + stream_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + occurred_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_stream_version + ON workflow_events(ticket_id, stream_version) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_board ( + board_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + workflow_file_path TEXT NOT NULL, + workflow_version_hash TEXT NOT NULL, + max_concurrent_tickets INTEGER NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket ( + ticket_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + current_lane_key TEXT NOT NULL, + status TEXT NOT NULL, + worktree_ref TEXT, + baseline_ref TEXT, + external_ref TEXT, + priority INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_board + ON projection_ticket(board_id) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_pipeline_run ( + pipeline_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + lane_key TEXT NOT NULL, + lane_entry_token TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_step_run ( + step_run_id TEXT PRIMARY KEY, + pipeline_run_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + step_key TEXT NOT NULL, + step_type TEXT NOT NULL, + status TEXT NOT NULL, + waiting_reason TEXT, + error TEXT, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_step_run_ticket + ON projection_step_run(ticket_id) + `; +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts new file mode 100644 index 00000000000..667f697b71e --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("workflow migration", (it) => { + it.effect("creates workflow_events and projection tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' + AND name IN ( + 'workflow_events', + 'projection_board', + 'projection_ticket', + 'projection_pipeline_run', + 'projection_step_run' + ) + `; + assert.equal(tables.length, 5); + }), + ); +}); From 79d9668f4ac8c2561484964066b511df68e995cc Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:00:37 -0400 Subject: [PATCH 013/295] feat(workflow): add WorkflowEventStore with per-ticket versioning Constraint: M1 Task 6 required append/replay behavior after missing-service RED test. Constraint: Server imports workflow schemas through @t3tools/contracts, requiring the contracts entrypoint export. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEventStore.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- .../Layers/WorkflowEventStore.test.ts | 53 +++++++ .../src/workflow/Layers/WorkflowEventStore.ts | 139 ++++++++++++++++++ apps/server/src/workflow/Services/Errors.ts | 9 ++ .../workflow/Services/WorkflowEventStore.ts | 30 ++++ packages/contracts/src/index.ts | 1 + 5 files changed, 232 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowEventStore.ts create mode 100644 apps/server/src/workflow/Services/Errors.ts create mode 100644 apps/server/src/workflow/Services/WorkflowEventStore.ts diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts index 667f697b71e..6efe5a344fb 100644 --- a/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts @@ -1,10 +1,13 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); @@ -26,3 +29,53 @@ layer("workflow migration", (it) => { }), ); }); + +const storeLayer = it.layer( + WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +storeLayer("WorkflowEventStore", (it) => { + it.effect("appends and replays a decoded event with assigned version", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const appended = yield* store.append({ + type: "TicketCreated", + eventId: "evt-a" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + assert.equal(appended.streamVersion, 0); + + const events = yield* Stream.runCollect(store.readByTicket("t-1" as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(events.length, 1); + assert.equal(events[0]?.type, "TicketCreated"); + }), + ); + + it.effect("assigns incrementing stream versions per ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + yield* store.append({ + type: "TicketCreated", + eventId: "evt-b" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "backlog" as never }, + }); + const second = yield* store.append({ + type: "TicketBlocked", + eventId: "evt-c" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "scope unclear" }, + }); + assert.equal(second.streamVersion, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.ts new file mode 100644 index 00000000000..fe3f1202d09 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.ts @@ -0,0 +1,139 @@ +import { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventStoreShape, +} from "../Services/WorkflowEventStore.ts"; + +interface Row { + readonly sequence: number; + readonly eventId: string; + readonly ticketId: string; + readonly streamVersion: number; + readonly type: string; + readonly occurredAt: string; + readonly payloadJson: string; +} + +const decodePayloadJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeWorkflowEvent = Schema.decodeUnknownEffect(WorkflowEvent); + +const toStoreError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const decodeEvent = (row: Row): Effect.Effect => + Effect.gen(function* () { + const payload = yield* decodePayloadJson(row.payloadJson); + const event = yield* decodeWorkflowEvent({ + type: row.type, + eventId: row.eventId, + ticketId: row.ticketId, + streamVersion: row.streamVersion, + occurredAt: row.occurredAt, + payload, + }); + return { ...event, sequence: row.sequence } as PersistedWorkflowEvent; + }).pipe(Effect.mapError(toStoreError("Failed to decode workflow event"))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const append: WorkflowEventStoreShape["append"] = (event) => + Effect.gen(function* () { + const rows = yield* sql` + INSERT INTO workflow_events + (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) + VALUES ( + ${event.eventId}, + ${event.ticketId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM workflow_events + WHERE ticket_id = ${event.ticketId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), + ${event.type}, + ${event.occurredAt}, + ${JSON.stringify(event.payload)} + ) + RETURNING + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + `; + const row = rows[0]; + if (!row) { + return yield* Effect.fail(new WorkflowEventStoreError({ message: "append returned no row" })); + } + return yield* decodeEvent(row); + }).pipe(Effect.mapError(toStoreError("append failed"))); + + const streamRows = ( + query: Effect.Effect, unknown>, + ): Stream.Stream => + Stream.fromEffect(query.pipe(Effect.mapError(toStoreError("read failed")))).pipe( + Stream.flatMap((rows) => Stream.fromIterable(rows)), + Stream.mapEffect(decodeEvent), + ); + + const readByTicket: WorkflowEventStoreShape["readByTicket"] = (ticketId) => + streamRows(sql` + SELECT + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = ${ticketId} + ORDER BY stream_version ASC + `); + + const readFromSequence: WorkflowEventStoreShape["readFromSequence"] = ( + sequenceExclusive, + limit = 1_000, + ) => { + const normalizedLimit = Math.max(0, Math.floor(limit)); + if (normalizedLimit === 0) { + return Stream.empty; + } + return streamRows(sql` + SELECT + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE sequence > ${sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${normalizedLimit} + `); + }; + + const readAll: WorkflowEventStoreShape["readAll"] = () => + readFromSequence(0, Number.MAX_SAFE_INTEGER); + + return { append, readByTicket, readFromSequence, readAll } satisfies WorkflowEventStoreShape; +}); + +export const WorkflowEventStoreLive = Layer.effect(WorkflowEventStore, make); diff --git a/apps/server/src/workflow/Services/Errors.ts b/apps/server/src/workflow/Services/Errors.ts new file mode 100644 index 00000000000..a659b1ddcd7 --- /dev/null +++ b/apps/server/src/workflow/Services/Errors.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +export class WorkflowEventStoreError extends Schema.TaggedErrorClass()( + "WorkflowEventStoreError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/apps/server/src/workflow/Services/WorkflowEventStore.ts b/apps/server/src/workflow/Services/WorkflowEventStore.ts new file mode 100644 index 00000000000..a485c8991c3 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventStore.ts @@ -0,0 +1,30 @@ +import type { TicketId, WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type PersistedWorkflowEvent = WorkflowEvent & { readonly sequence: number }; + +type DistributiveOmit = T extends unknown ? Omit : never; +export type WorkflowEventInput = DistributiveOmit; + +export interface WorkflowEventStoreShape { + readonly append: ( + event: WorkflowEventInput, + ) => Effect.Effect; + readonly readByTicket: ( + ticketId: TicketId, + ) => Stream.Stream; + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + readonly readAll: () => Stream.Stream; +} + +export class WorkflowEventStore extends Context.Service< + WorkflowEventStore, + WorkflowEventStoreShape +>()("t3/workflow/Services/WorkflowEventStore") {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 163d5e236cd..e52815d2a18 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -23,3 +23,4 @@ export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./review.ts"; export * from "./rpc.ts"; +export * from "./workflow.ts"; From cd5ce1f7fa50d209cfcaa386d814460fce2513d6 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:01:36 -0400 Subject: [PATCH 014/295] feat(workflow): add board/ticket projection pipeline Constraint: M1 Task 7 required projection-only behavior with no engine or RPC logic. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowProjectionPipeline.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- .../Layers/WorkflowProjectionPipeline.test.ts | 116 +++++++++++ .../Layers/WorkflowProjectionPipeline.ts | 191 ++++++++++++++++++ .../Services/WorkflowProjectionPipeline.ts | 14 ++ 3 files changed, 321 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts create mode 100644 apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts new file mode 100644 index 00000000000..74b021657e9 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts @@ -0,0 +1,116 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; + +const layer = it.layer( + WorkflowProjectionPipelineLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowProjectionPipeline", (it) => { + it.effect("projects TicketCreated then TicketMovedToLane into projection_ticket", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Export CSV" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMovedToLane", + eventId: "e2" as never, + ticketId: "t-1" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-1" as never, + reason: "routed", + }, + }); + + const rows = yield* sql<{ readonly currentLaneKey: string; readonly status: string }>` + SELECT current_lane_key AS "currentLaneKey", status + FROM projection_ticket + WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.currentLaneKey, "implement"); + assert.equal(rows[0]?.status, "idle"); + }), + ); + + it.effect("projects step lifecycle and waiting_on_user status", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-1" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-1" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "d" as never, + streamVersion: 3, + payload: { stepRunId: "sr-1" as never, waitingReason: "which API?" }, + }); + + const ticket = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_ticket WHERE ticket_id = 't-2' + `; + const step = yield* sql<{ readonly status: string; readonly waitingReason: string }>` + SELECT status, waiting_reason AS "waitingReason" + FROM projection_step_run + WHERE step_run_id = 'sr-1' + `; + assert.equal(ticket[0]?.status, "waiting_on_user"); + assert.equal(step[0]?.status, "awaiting_user"); + assert.equal(step[0]?.waitingReason, "which API?"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..b1658c3b93e --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -0,0 +1,191 @@ +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowProjectionPipeline, + type WorkflowProjectionPipelineShape, +} from "../Services/WorkflowProjectionPipeline.ts"; + +const toProjectionError = (cause: unknown) => + new WorkflowEventStoreError({ message: "projection failed", cause }); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const projectEvent: WorkflowProjectionPipelineShape["projectEvent"] = (event: WorkflowEvent) => + Effect.gen(function* () { + switch (event.type) { + case "TicketCreated": { + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + description, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${event.ticketId}, + ${event.payload.boardId}, + ${event.payload.title}, + ${event.payload.description ?? null}, + ${event.payload.laneKey}, + 'idle', + ${event.occurredAt}, + ${event.occurredAt} + ) + ON CONFLICT(ticket_id) DO NOTHING + `; + break; + } + case "TicketMovedToLane": { + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, + status = 'idle', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketRouted": { + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketBlocked": { + yield* sql` + UPDATE projection_ticket + SET status = 'blocked', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineStarted": { + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES ( + ${event.payload.pipelineRunId}, + ${event.ticketId}, + ${event.payload.laneKey}, + ${event.payload.laneEntryToken}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(pipeline_run_id) DO NOTHING + `; + yield* sql` + UPDATE projection_ticket + SET status = 'running', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineCompleted": { + yield* sql` + UPDATE projection_pipeline_run + SET status = ${event.payload.result}, + finished_at = ${event.occurredAt} + WHERE pipeline_run_id = ${event.payload.pipelineRunId} + `; + break; + } + case "StepStarted": { + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES ( + ${event.payload.stepRunId}, + ${event.payload.pipelineRunId}, + ${event.ticketId}, + ${event.payload.stepKey}, + ${event.payload.stepType}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(step_run_id) DO NOTHING + `; + break; + } + case "StepAwaitingUser": { + yield* sql` + UPDATE projection_step_run + SET status = 'awaiting_user', + waiting_reason = ${event.payload.waitingReason} + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket + SET status = 'waiting_on_user', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepUserResolved": { + yield* sql` + UPDATE projection_step_run + SET status = 'running', + waiting_reason = NULL + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket + SET status = 'running', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepCompleted": { + yield* sql` + UPDATE projection_step_run + SET status = 'completed', + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepFailed": { + yield* sql` + UPDATE projection_step_run + SET status = 'failed', + error = ${event.payload.error}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + } + }).pipe(Effect.mapError(toProjectionError), Effect.asVoid); + + return { projectEvent } satisfies WorkflowProjectionPipelineShape; +}); + +export const WorkflowProjectionPipelineLive = Layer.effect(WorkflowProjectionPipeline, make); diff --git a/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..809b209d81e --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts @@ -0,0 +1,14 @@ +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowProjectionPipelineShape { + readonly projectEvent: (event: WorkflowEvent) => Effect.Effect; +} + +export class WorkflowProjectionPipeline extends Context.Service< + WorkflowProjectionPipeline, + WorkflowProjectionPipelineShape +>()("t3/workflow/Services/WorkflowProjectionPipeline") {} From 8632dd784eb77306c1d9cf67c357a7610c2e5b36 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:02:31 -0400 Subject: [PATCH 015/295] feat(workflow): add WorkflowReadModel query service Constraint: M1 Task 8 required read-side query behavior only, with projection writes driven by the existing Task 7 pipeline. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowReadModel.test.ts Not-tested: Full milestone gates deferred until M1 completion. --- .../workflow/Layers/WorkflowReadModel.test.ts | 93 +++++++++++++++ .../src/workflow/Layers/WorkflowReadModel.ts | 108 ++++++++++++++++++ .../workflow/Services/WorkflowReadModel.ts | 58 ++++++++++ 3 files changed, 259 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowReadModel.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowReadModel.ts create mode 100644 apps/server/src/workflow/Services/WorkflowReadModel.ts diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts new file mode 100644 index 00000000000..3f4c36e85f7 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts @@ -0,0 +1,93 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const layer = it.layer( + Layer.mergeAll(WorkflowReadModelLive, WorkflowProjectionPipelineLive).pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowReadModel", (it) => { + it.effect("registers a board and lists its tickets", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + + yield* read.registerBoard({ + boardId: "b-1" as never, + projectId: "p-1" as never, + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash1", + maxConcurrentTickets: 3, + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Export" as never, laneKey: "backlog" as never }, + }); + + const board = yield* read.getBoard("b-1" as never); + assert.equal(board?.name, "Delivery"); + const tickets = yield* read.listTickets("b-1" as never); + assert.equal(tickets.length, 1); + assert.equal(tickets[0]?.title, "Export"); + }), + ); + + it.effect("returns ticket detail with step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { ticketId: "t-9" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { boardId: "b-1" as never, title: "Z" as never, laneKey: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr" as never, + laneKey: "implement" as never, + laneEntryToken: "tok" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr" as never, + stepRunId: "sr" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + + const detail = yield* read.getTicketDetail("t-9" as never); + assert.equal(detail?.ticket.title, "Z"); + assert.equal(detail?.steps.length, 1); + assert.equal(detail?.steps[0]?.stepKey, "code"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts new file mode 100644 index 00000000000..4d8e5f2f61f --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -0,0 +1,108 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowReadModel, + type BoardRow, + type StepRunRow, + type TicketRow, + type WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; + +const toReadModelError = (cause: unknown) => + new WorkflowEventStoreError({ message: "read failed", cause }); + +const wrap =
(effect: Effect.Effect) => effect.pipe(Effect.mapError(toReadModelError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const registerBoard: WorkflowReadModelShape["registerBoard"] = (board) => + wrap(sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + ${board.boardId}, + ${board.projectId}, + ${board.name}, + ${board.workflowFilePath}, + ${board.workflowVersionHash}, + ${board.maxConcurrentTickets} + ) + ON CONFLICT(board_id) DO UPDATE SET + project_id = excluded.project_id, + name = excluded.name, + workflow_file_path = excluded.workflow_file_path, + workflow_version_hash = excluded.workflow_version_hash, + max_concurrent_tickets = excluded.max_concurrent_tickets + `).pipe(Effect.asVoid); + + const getBoard: WorkflowReadModelShape["getBoard"] = (boardId) => + wrap(sql` + SELECT + board_id AS "boardId", + project_id AS "projectId", + name, + workflow_file_path AS "workflowFilePath", + workflow_version_hash AS "workflowVersionHash", + max_concurrent_tickets AS "maxConcurrentTickets" + FROM projection_board + WHERE board_id = ${boardId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const listTickets: WorkflowReadModelShape["listTickets"] = (boardId) => + wrap(sql` + SELECT + ticket_id AS "ticketId", + board_id AS "boardId", + title, + current_lane_key AS "currentLaneKey", + status + FROM projection_ticket + WHERE board_id = ${boardId} + ORDER BY created_at ASC + `); + + const getTicketDetail: WorkflowReadModelShape["getTicketDetail"] = (ticketId) => + Effect.gen(function* () { + const ticketRows = yield* wrap(sql` + SELECT + ticket_id AS "ticketId", + board_id AS "boardId", + title, + current_lane_key AS "currentLaneKey", + status + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `); + const ticket = ticketRows[0]; + if (!ticket) { + return null; + } + + const steps = yield* wrap(sql` + SELECT + step_run_id AS "stepRunId", + step_key AS "stepKey", + step_type AS "stepType", + status, + waiting_reason AS "waitingReason" + FROM projection_step_run + WHERE ticket_id = ${ticketId} + ORDER BY started_at ASC + `); + return { ticket, steps }; + }); + + return { registerBoard, getBoard, listTickets, getTicketDetail } satisfies WorkflowReadModelShape; +}); + +export const WorkflowReadModelLive = Layer.effect(WorkflowReadModel, make); diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts new file mode 100644 index 00000000000..0a7010385c2 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -0,0 +1,58 @@ +import type { BoardId, ProjectId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface BoardRow { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; +} + +export interface TicketRow { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly currentLaneKey: string; + readonly status: string; +} + +export interface StepRunRow { + readonly stepRunId: string; + readonly stepKey: string; + readonly stepType: string; + readonly status: string; + readonly waitingReason: string | null; +} + +export interface TicketDetail { + readonly ticket: TicketRow; + readonly steps: ReadonlyArray; +} + +export interface WorkflowReadModelShape { + readonly registerBoard: (board: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + }) => Effect.Effect; + readonly getBoard: (boardId: BoardId) => Effect.Effect; + readonly listTickets: ( + boardId: BoardId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly getTicketDetail: ( + ticketId: TicketId, + ) => Effect.Effect; +} + +export class WorkflowReadModel extends Context.Service< + WorkflowReadModel, + WorkflowReadModelShape +>()("t3/workflow/Services/WorkflowReadModel") {} From d8c764439ce6d65b0ca60573de2753acb8885328 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:04:07 -0400 Subject: [PATCH 016/295] feat(workflow): aggregate foundation layer (store + projections + read model) Constraint: M1 Task 9 required aggregate layer plus server typecheck passing before the milestone DoD. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/WorkflowFoundationLive.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Workspace-wide vp gates deferred to milestone DoD after this commit. --- .../src/workflow/Layers/WorkflowEventStore.ts | 9 ++++--- .../src/workflow/Layers/WorkflowReadModel.ts | 4 ++- .../workflow/Services/WorkflowReadModel.ts | 7 +++-- .../workflow/WorkflowFoundationLive.test.ts | 27 +++++++++++++++++++ .../src/workflow/WorkflowFoundationLive.ts | 11 ++++++++ apps/server/src/workflow/workflowFile.ts | 14 +++++----- packages/contracts/src/workflow.test.ts | 14 ++++++---- 7 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/workflow/WorkflowFoundationLive.test.ts create mode 100644 apps/server/src/workflow/WorkflowFoundationLive.ts diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.ts index fe3f1202d09..febadca4f80 100644 --- a/apps/server/src/workflow/Layers/WorkflowEventStore.ts +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { @@ -24,6 +25,7 @@ interface Row { const decodePayloadJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const decodeWorkflowEvent = Schema.decodeUnknownEffect(WorkflowEvent); +const encodePayloadJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); const toStoreError = (message: string) => (cause: unknown) => new WorkflowEventStoreError({ message, cause }); @@ -47,6 +49,7 @@ const make = Effect.gen(function* () { const append: WorkflowEventStoreShape["append"] = (event) => Effect.gen(function* () { + const payloadJson = yield* encodePayloadJson(event.payload); const rows = yield* sql` INSERT INTO workflow_events (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) @@ -65,7 +68,7 @@ const make = Effect.gen(function* () { ), ${event.type}, ${event.occurredAt}, - ${JSON.stringify(event.payload)} + ${payloadJson} ) RETURNING sequence, @@ -78,13 +81,13 @@ const make = Effect.gen(function* () { `; const row = rows[0]; if (!row) { - return yield* Effect.fail(new WorkflowEventStoreError({ message: "append returned no row" })); + return yield* new WorkflowEventStoreError({ message: "append returned no row" }); } return yield* decodeEvent(row); }).pipe(Effect.mapError(toStoreError("append failed"))); const streamRows = ( - query: Effect.Effect, unknown>, + query: Effect.Effect, SqlError>, ): Stream.Stream => Stream.fromEffect(query.pipe(Effect.mapError(toStoreError("read failed")))).pipe( Stream.flatMap((rows) => Stream.fromIterable(rows)), diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts index 4d8e5f2f61f..177d96baf45 100644 --- a/apps/server/src/workflow/Layers/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { @@ -14,7 +15,8 @@ import { const toReadModelError = (cause: unknown) => new WorkflowEventStoreError({ message: "read failed", cause }); -const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toReadModelError)); +const wrap = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toReadModelError)); const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts index 0a7010385c2..c5c82da95db 100644 --- a/apps/server/src/workflow/Services/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -52,7 +52,6 @@ export interface WorkflowReadModelShape { ) => Effect.Effect; } -export class WorkflowReadModel extends Context.Service< - WorkflowReadModel, - WorkflowReadModelShape ->()("t3/workflow/Services/WorkflowReadModel") {} +export class WorkflowReadModel extends Context.Service()( + "t3/workflow/Services/WorkflowReadModel", +) {} diff --git a/apps/server/src/workflow/WorkflowFoundationLive.test.ts b/apps/server/src/workflow/WorkflowFoundationLive.test.ts new file mode 100644 index 00000000000..e9cd3c3a393 --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +const layer = it.layer( + WorkflowFoundationLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowFoundationLive", (it) => { + it.effect("provides event store and read model together", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const read = yield* WorkflowReadModel; + assert.isDefined(store.append); + assert.isDefined(read.getBoard); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowFoundationLive.ts b/apps/server/src/workflow/WorkflowFoundationLive.ts new file mode 100644 index 00000000000..259cb342f4b --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.ts @@ -0,0 +1,11 @@ +import * as Layer from "effect/Layer"; + +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowProjectionPipelineLive } from "./Layers/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +export const WorkflowFoundationLive = Layer.mergeAll( + WorkflowEventStoreLive, + WorkflowProjectionPipelineLive, + WorkflowReadModelLive, +); diff --git a/apps/server/src/workflow/workflowFile.ts b/apps/server/src/workflow/workflowFile.ts index 8314661f40c..0901319e799 100644 --- a/apps/server/src/workflow/workflowFile.ts +++ b/apps/server/src/workflow/workflowFile.ts @@ -26,8 +26,8 @@ const routingTargets = (lane: WorkflowLane): ReadonlyArray => { if (!on) { return []; } - return [on.success, on.failure, on.blocked].filter( - (target): target is string => typeof target === "string", + return [on.success, on.failure, on.blocked].flatMap((target) => + target === undefined ? [] : [target as string], ); }; @@ -37,7 +37,7 @@ export const lintWorkflowDefinition = ( ): ReadonlyArray => { const errors: LintError[] = []; const laneKeys = new Set(); - const allKeys = def.lanes.map((lane) => lane.key as string); + const allKeys = new Set(def.lanes.map((lane) => lane.key as string)); for (const lane of def.lanes) { const laneKey = lane.key as string; @@ -90,7 +90,7 @@ export const lintWorkflowDefinition = ( } for (const target of routingTargets(lane)) { - if (!allKeys.includes(target)) { + if (!allKeys.has(target)) { errors.push({ code: "missing_lane_ref", laneKey, @@ -100,7 +100,9 @@ export const lintWorkflowDefinition = ( } } - const byKey = new Map(def.lanes.map((lane) => [lane.key as string, lane] as const)); + const byKey = new Map( + def.lanes.map((lane) => [lane.key as string, lane] as const), + ); for (const lane of def.lanes) { if (lane.entry !== "auto") { continue; @@ -119,7 +121,7 @@ export const lintWorkflowDefinition = ( break; } seen.add(cursorKey); - const next = cursor.on?.success; + const next = cursor.on?.success as string | undefined; cursor = next ? byKey.get(next) : undefined; } } diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts index 5cfa2fe19ba..b87c9eaf8f1 100644 --- a/packages/contracts/src/workflow.test.ts +++ b/packages/contracts/src/workflow.test.ts @@ -11,6 +11,10 @@ import { WorkflowEventId, } from "./workflow.ts"; +const decodeTicketId = Schema.decodeUnknownEffect(TicketId); +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowEvent = Schema.decodeUnknownEffect(WorkflowEvent); + describe("workflow ids", () => { it("brands a board id from a non-empty string", () => { const id = BoardId.make("board-123"); @@ -19,7 +23,7 @@ describe("workflow ids", () => { it.effect("rejects an empty ticket id", () => Effect.gen(function* () { - const result = yield* Effect.exit(Schema.decodeUnknownEffect(TicketId)("")); + const result = yield* Effect.exit(decodeTicketId("")); assert.strictEqual(result._tag, "Failure"); }), ); @@ -64,7 +68,7 @@ describe("WorkflowDefinition", () => { it.effect("decodes a valid workflow file", () => Effect.gen(function* () { - const decoded = yield* Schema.decodeUnknownEffect(WorkflowDefinition)(example); + const decoded = yield* decodeWorkflowDefinition(example); assert.equal(decoded.lanes.length, 5); assert.equal(decoded.lanes[1]?.pipeline?.length, 2); }), @@ -73,7 +77,7 @@ describe("WorkflowDefinition", () => { it.effect("rejects an unknown step type", () => Effect.gen(function* () { const result = yield* Effect.exit( - Schema.decodeUnknownEffect(WorkflowDefinition)({ + decodeWorkflowDefinition({ name: "x", lanes: [ { @@ -102,14 +106,14 @@ describe("WorkflowEvent", () => { it.effect("decodes a TicketCreated event", () => Effect.gen(function* () { - const event = yield* Schema.decodeUnknownEffect(WorkflowEvent)(ticketCreated); + const event = yield* decodeWorkflowEvent(ticketCreated); assert.equal(event.type, "TicketCreated"); }), ); it.effect("decodes a TicketMovedToLane event", () => Effect.gen(function* () { - const event = yield* Schema.decodeUnknownEffect(WorkflowEvent)({ + const event = yield* decodeWorkflowEvent({ type: "TicketMovedToLane", eventId: "evt-2", ticketId: "t-1", From bc2092e728cedb370da0754c96e3a8356c36103b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:06:44 -0400 Subject: [PATCH 017/295] feat(workflow): add current_lane_entry_token to projection_ticket Constraint: M2 Task 1 required assigning the next migration ID from the real registry after M1's 033 migration. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEngine.token-migration.test.ts Not-tested: Full milestone gates deferred until M2 completion. --- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/034_WorkflowTicketToken.ts | 8 +++++++ .../WorkflowEngine.token-migration.test.ts | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 00548b01b3d..091af9ab936 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_WorkflowEvents.ts"; +import Migration0034 from "./Migrations/034_WorkflowTicketToken.ts"; /** * Migration loader with all migrations defined inline. @@ -91,6 +92,7 @@ export const migrationEntries = [ [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "WorkflowEvents", Migration0033], + [34, "WorkflowTicketToken", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts b/apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts new file mode 100644 index 00000000000..666212dad94 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts @@ -0,0 +1,8 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_ticket ADD COLUMN current_lane_entry_token TEXT`; +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts new file mode 100644 index 00000000000..06b84387f2c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("ticket token migration", (it) => { + it.effect("projection_ticket has current_lane_entry_token", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_ticket) + `; + assert.isTrue(columns.some((column) => column.name === "current_lane_entry_token")); + }), + ); +}); From 5ddf7d0ad833faf22df1aba7e1d6b01df66af8ed Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:07:11 -0400 Subject: [PATCH 018/295] feat(workflow): project current lane-entry token onto tickets Constraint: M2 Task 1 requires the projection to expose lane-entry tokens for stale-run guards. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowProjectionPipeline.test.ts Not-tested: Full milestone gates deferred until M2 completion. --- .../Layers/WorkflowProjectionPipeline.test.ts | 12 ++++++++++-- .../workflow/Layers/WorkflowProjectionPipeline.ts | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts index 74b021657e9..f53cc60dbd3 100644 --- a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts @@ -46,11 +46,19 @@ layer("WorkflowProjectionPipeline", (it) => { }, }); - const rows = yield* sql<{ readonly currentLaneKey: string; readonly status: string }>` - SELECT current_lane_key AS "currentLaneKey", status + const rows = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + status FROM projection_ticket WHERE ticket_id = 't-1' `; + assert.equal(rows[0]?.currentLaneEntryToken, "tok-1"); assert.equal(rows[0]?.currentLaneKey, "implement"); assert.equal(rows[0]?.status, "idle"); }), diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts index b1658c3b93e..ccd76c9b62f 100644 --- a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -49,6 +49,7 @@ const make = Effect.gen(function* () { UPDATE projection_ticket SET current_lane_key = ${event.payload.toLane}, status = 'idle', + current_lane_entry_token = ${event.payload.laneEntryToken}, updated_at = ${event.occurredAt} WHERE ticket_id = ${event.ticketId} `; From 7974494c0e13b8f6025d1a17a520d7cb900f4108 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:08:08 -0400 Subject: [PATCH 019/295] feat(workflow): add WorkflowIds id/token seam Constraint: M2 Task 2 required deterministic IDs for tests and repo-canonical UUID generation for live IDs. Rejected: crypto.randomUUID() | repo server code uses Effect Crypto.randomUUIDv4. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowIds.test.ts Not-tested: Full milestone gates deferred until M2 completion. --- .../src/workflow/Layers/WorkflowIds.test.ts | 19 +++++++ .../server/src/workflow/Layers/WorkflowIds.ts | 50 +++++++++++++++++++ .../src/workflow/Services/WorkflowIds.ts | 21 ++++++++ 3 files changed, 90 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowIds.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowIds.ts create mode 100644 apps/server/src/workflow/Services/WorkflowIds.ts diff --git a/apps/server/src/workflow/Layers/WorkflowIds.test.ts b/apps/server/src/workflow/Layers/WorkflowIds.test.ts new file mode 100644 index 00000000000..9a6df6acb29 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer(DeterministicWorkflowIds); + +layer("DeterministicWorkflowIds", (it) => { + it.effect("produces stable, prefixed, incrementing ids", () => + Effect.gen(function* () { + const ids = yield* WorkflowIds; + assert.equal(yield* ids.ticketId(), "ticket-1"); + assert.equal(yield* ids.ticketId(), "ticket-2"); + assert.equal(yield* ids.token(), "token-1"); + assert.equal(yield* ids.stepRunId(), "steprun-1"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowIds.ts b/apps/server/src/workflow/Layers/WorkflowIds.ts new file mode 100644 index 00000000000..996c839c003 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.ts @@ -0,0 +1,50 @@ +import { + LaneEntryToken, + PipelineRunId, + StepRunId, + TicketId, + WorkflowEventId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { WorkflowIds, type WorkflowIdsShape } from "../Services/WorkflowIds.ts"; + +export const DeterministicWorkflowIds = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const counters = yield* Ref.make>({}); + const next = (prefix: string) => + Ref.modify(counters, (counters) => { + const value = (counters[prefix] ?? 0) + 1; + return [`${prefix}-${value}`, { ...counters, [prefix]: value }] as const; + }); + + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + } satisfies WorkflowIdsShape; + }), +); + +export const WorkflowIdsLive = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const next = (prefix: string) => + crypto.randomUUIDv4.pipe(Effect.map((uuid) => `${prefix}-${uuid}`)); + + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + } satisfies WorkflowIdsShape; + }), +); diff --git a/apps/server/src/workflow/Services/WorkflowIds.ts b/apps/server/src/workflow/Services/WorkflowIds.ts new file mode 100644 index 00000000000..ccd37f854c5 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowIds.ts @@ -0,0 +1,21 @@ +import type { + LaneEntryToken, + PipelineRunId, + StepRunId, + TicketId, + WorkflowEventId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowIdsShape { + readonly ticketId: () => Effect.Effect; + readonly pipelineRunId: () => Effect.Effect; + readonly stepRunId: () => Effect.Effect; + readonly eventId: () => Effect.Effect; + readonly token: () => Effect.Effect; +} + +export class WorkflowIds extends Context.Service()( + "t3/workflow/Services/WorkflowIds", +) {} From 2bd8247ed25ddecefbdc2fec7f51a0becc44f2dc Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:08:46 -0400 Subject: [PATCH 020/295] feat(workflow): add WorkflowEventCommitter (append+project) Constraint: M2 Task 3 requires a single append-then-project path for engine-emitted events. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEventCommitter.test.ts Not-tested: Full milestone gates deferred until M2 completion. --- .../Layers/WorkflowEventCommitter.test.ts | 40 +++++++++++++++++++ .../workflow/Layers/WorkflowEventCommitter.ts | 24 +++++++++++ .../Services/WorkflowEventCommitter.ts | 14 +++++++ 3 files changed, 78 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEventCommitter.ts create mode 100644 apps/server/src/workflow/Services/WorkflowEventCommitter.ts diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts new file mode 100644 index 00000000000..3799fe8a78c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts @@ -0,0 +1,40 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEventCommitter", (it) => { + it.effect("appends and projects in one call", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + + const rows = yield* sql<{ readonly title: string }>` + SELECT title FROM projection_ticket WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.title, "X"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..a48bffaf677 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; + +const make = Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const pipeline = yield* WorkflowProjectionPipeline; + + const commit: WorkflowEventCommitterShape["commit"] = (event) => + Effect.gen(function* () { + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + }); + + return { commit } satisfies WorkflowEventCommitterShape; +}); + +export const WorkflowEventCommitterLive = Layer.effect(WorkflowEventCommitter, make); diff --git a/apps/server/src/workflow/Services/WorkflowEventCommitter.ts b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..518d984994e --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts @@ -0,0 +1,14 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; +import type { WorkflowEventInput } from "./WorkflowEventStore.ts"; + +export interface WorkflowEventCommitterShape { + readonly commit: (event: WorkflowEventInput) => Effect.Effect; +} + +export class WorkflowEventCommitter extends Context.Service< + WorkflowEventCommitter, + WorkflowEventCommitterShape +>()("t3/workflow/Services/WorkflowEventCommitter") {} From 35b32e3eaf5aaa5e14c6393bb390b9a4a6f02f0d Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:09:43 -0400 Subject: [PATCH 021/295] feat(workflow): add BoardRegistry for parsed workflow definitions Constraint: M2 Task 4 keeps workflow definitions in memory and validates them through the M1 schema/linter. Rejected: Effect.either test helper from the plan | not callable in this Effect beta; used Effect.exit instead. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/BoardRegistry.test.ts Not-tested: Full milestone gates deferred until M2 completion. --- .../src/workflow/Layers/BoardRegistry.test.ts | 54 +++++++++++++++++++ .../src/workflow/Layers/BoardRegistry.ts | 54 +++++++++++++++++++ .../src/workflow/Services/BoardRegistry.ts | 25 +++++++++ 3 files changed, 133 insertions(+) create mode 100644 apps/server/src/workflow/Layers/BoardRegistry.test.ts create mode 100644 apps/server/src/workflow/Layers/BoardRegistry.ts create mode 100644 apps/server/src/workflow/Services/BoardRegistry.ts diff --git a/apps/server/src/workflow/Layers/BoardRegistry.test.ts b/apps/server/src/workflow/Layers/BoardRegistry.test.ts new file mode 100644 index 00000000000..6158fd759b6 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.test.ts @@ -0,0 +1,54 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; + +const layer = it.layer(BoardRegistryLive); + +const def = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +layer("BoardRegistry", (it) => { + it.effect("registers a definition and resolves lanes", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, def); + const lane = yield* registry.getLane("b-1" as never, "impl" as never); + assert.equal(lane?.entry, "auto"); + assert.equal(lane?.pipeline?.length, 1); + }), + ); + + it.effect("rejects an invalid definition", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const result = yield* Effect.exit( + registry.register("b-2" as never, { + name: "bad", + lanes: [{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }], + }), + ); + assert.equal(result._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/BoardRegistry.ts b/apps/server/src/workflow/Layers/BoardRegistry.ts new file mode 100644 index 00000000000..43daff5a988 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.ts @@ -0,0 +1,54 @@ +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { + BoardRegistry, + BoardRegistryError, + type BoardRegistryShape, +} from "../Services/BoardRegistry.ts"; +import { lintWorkflowDefinition } from "../workflowFile.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); + +const make = Effect.gen(function* () { + const store = yield* Ref.make>(new Map()); + + const register: BoardRegistryShape["register"] = (boardId, raw) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition(raw).pipe( + Effect.mapError( + (cause) => new BoardRegistryError({ message: `Invalid workflow: ${String(cause)}` }), + ), + ); + const errors = lintWorkflowDefinition(definition, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }); + if (errors.length > 0) { + return yield* new BoardRegistryError({ + message: `Workflow lint failed: ${errors.map((error) => error.code).join(", ")}`, + }); + } + + yield* Ref.update(store, (current) => new Map(current).set(boardId as string, definition)); + return definition; + }); + + const getDefinition: BoardRegistryShape["getDefinition"] = (boardId) => + Ref.get(store).pipe(Effect.map((current) => current.get(boardId as string) ?? null)); + + const getLane: BoardRegistryShape["getLane"] = (boardId, laneKey) => + getDefinition(boardId).pipe( + Effect.map( + (definition) => + definition?.lanes.find((lane) => lane.key === laneKey) ?? null, + ), + ); + + return { register, getDefinition, getLane } satisfies BoardRegistryShape; +}); + +export const BoardRegistryLive = Layer.effect(BoardRegistry, make); diff --git a/apps/server/src/workflow/Services/BoardRegistry.ts b/apps/server/src/workflow/Services/BoardRegistry.ts new file mode 100644 index 00000000000..f7f847adf28 --- /dev/null +++ b/apps/server/src/workflow/Services/BoardRegistry.ts @@ -0,0 +1,25 @@ +import type { BoardId, LaneKey, WorkflowDefinition, WorkflowLane } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class BoardRegistryError extends Schema.TaggedErrorClass()( + "BoardRegistryError", + { message: Schema.String }, +) {} + +export interface BoardRegistryShape { + readonly register: ( + boardId: BoardId, + definition: unknown, + ) => Effect.Effect; + readonly getDefinition: (boardId: BoardId) => Effect.Effect; + readonly getLane: ( + boardId: BoardId, + laneKey: LaneKey, + ) => Effect.Effect; +} + +export class BoardRegistry extends Context.Service()( + "t3/workflow/Services/BoardRegistry", +) {} From 8517238997994c7af73cd18ff017e314d561d96f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:10:33 -0400 Subject: [PATCH 022/295] feat(workflow): add StepExecutor port and stub + StepOutcome contract Constraint: M2 Task 5 keeps provider execution behind a stubbable port; real provider dispatch remains M3. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/StubStepExecutor.test.ts Not-tested: Full milestone gates deferred until M2 completion. --- .../workflow/Layers/StubStepExecutor.test.ts | 29 +++++++++++++++++++ .../src/workflow/Layers/StubStepExecutor.ts | 16 ++++++++++ .../src/workflow/Services/StepExecutor.ts | 28 ++++++++++++++++++ packages/contracts/src/workflow.ts | 7 +++++ 4 files changed, 80 insertions(+) create mode 100644 apps/server/src/workflow/Layers/StubStepExecutor.test.ts create mode 100644 apps/server/src/workflow/Layers/StubStepExecutor.ts create mode 100644 apps/server/src/workflow/Services/StepExecutor.ts diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.test.ts b/apps/server/src/workflow/Layers/StubStepExecutor.test.ts new file mode 100644 index 00000000000..891c6668eff --- /dev/null +++ b/apps/server/src/workflow/Layers/StubStepExecutor.test.ts @@ -0,0 +1,29 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +const layer = it.layer(makeStubStepExecutor({ default: { _tag: "completed" } })); + +layer("StubStepExecutor", (it) => { + it.effect("returns the scripted default outcome", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const outcome = yield* executor.execute({ + ticketId: "t-1" as never, + boardId: "b-1" as never, + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + laneEntryToken: "tok-1" as never, + step: { + key: "code" as never, + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "x", + }, + }); + assert.equal(outcome._tag, "completed"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.ts b/apps/server/src/workflow/Layers/StubStepExecutor.ts new file mode 100644 index 00000000000..b09a8eb8025 --- /dev/null +++ b/apps/server/src/workflow/Layers/StubStepExecutor.ts @@ -0,0 +1,16 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; + +export interface StubScript { + readonly default: StepOutcome; + readonly byStepKey?: Record; +} + +export const makeStubStepExecutor = (script: StubScript): Layer.Layer => + Layer.succeed(StepExecutor, { + execute: (ctx) => + Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), + } satisfies StepExecutorShape); diff --git a/apps/server/src/workflow/Services/StepExecutor.ts b/apps/server/src/workflow/Services/StepExecutor.ts new file mode 100644 index 00000000000..558c117af90 --- /dev/null +++ b/apps/server/src/workflow/Services/StepExecutor.ts @@ -0,0 +1,28 @@ +import type { + BoardId, + LaneEntryToken, + PipelineRunId, + StepOutcome, + StepRunId, + TicketId, + WorkflowStep, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface StepExecutionContext { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly pipelineRunId: PipelineRunId; + readonly stepRunId: StepRunId; + readonly laneEntryToken: LaneEntryToken; + readonly step: WorkflowStep; +} + +export interface StepExecutorShape { + readonly execute: (ctx: StepExecutionContext) => Effect.Effect; +} + +export class StepExecutor extends Context.Service()( + "t3/workflow/Services/StepExecutor", +) {} diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 3739ffd9694..9269287314e 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -207,3 +207,10 @@ export const WorkflowEvent = Schema.Union([ }), ]); export type WorkflowEvent = typeof WorkflowEvent.Type; + +export const StepOutcome = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("completed") }), + Schema.Struct({ _tag: Schema.Literal("failed"), error: Schema.String }), + Schema.Struct({ _tag: Schema.Literal("awaiting_user"), waitingReason: Schema.String }), +]); +export type StepOutcome = typeof StepOutcome.Type; From 580349c8a7d269134f2d4ce9813e6635bd649f78 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:12:22 -0400 Subject: [PATCH 023/295] feat(workflow): add in-memory ApprovalGate Constraint: M2 Task 6 keeps approvals in process; durable approval persistence is deferred to M3. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/ApprovalGate.test.ts Not-tested: Full milestone suite runs after remaining M2 engine tasks. --- .../src/workflow/Layers/ApprovalGate.test.ts | 21 ++++++++++++ .../src/workflow/Layers/ApprovalGate.ts | 34 +++++++++++++++++++ .../src/workflow/Services/ApprovalGate.ts | 12 +++++++ 3 files changed, 67 insertions(+) create mode 100644 apps/server/src/workflow/Layers/ApprovalGate.test.ts create mode 100644 apps/server/src/workflow/Layers/ApprovalGate.ts create mode 100644 apps/server/src/workflow/Services/ApprovalGate.ts diff --git a/apps/server/src/workflow/Layers/ApprovalGate.test.ts b/apps/server/src/workflow/Layers/ApprovalGate.test.ts new file mode 100644 index 00000000000..4697a4fe47f --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; + +const layer = it.layer(ApprovalGateLive); + +layer("ApprovalGate", (it) => { + it.effect("await resolves once resolve is called", () => + Effect.gen(function* () { + const gate = yield* ApprovalGate; + const fiber = yield* Effect.forkChild(gate.await("sr-1" as never)); + yield* Effect.yieldNow; + yield* gate.resolve("sr-1" as never, true); + const approved = yield* Fiber.join(fiber); + assert.equal(approved, true); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ApprovalGate.ts b/apps/server/src/workflow/Layers/ApprovalGate.ts new file mode 100644 index 00000000000..879471ba12b --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.ts @@ -0,0 +1,34 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; + +export const ApprovalGateLive = Layer.effect( + ApprovalGate, + Effect.gen(function* () { + const pending = yield* Ref.make(new Map>()); + + const getOrCreate = (stepRunId: string) => + Effect.gen(function* () { + const existing = (yield* Ref.get(pending)).get(stepRunId); + if (existing) { + return existing; + } + + const deferred = yield* Deferred.make(); + yield* Ref.update(pending, (current) => new Map(current).set(stepRunId, deferred)); + return deferred; + }); + + return ApprovalGate.of({ + await: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.flatMap(Deferred.await)), + resolve: (stepRunId, approved) => + getOrCreate(stepRunId).pipe( + Effect.flatMap((deferred) => Deferred.succeed(deferred, approved)), + Effect.asVoid, + ), + }); + }), +); diff --git a/apps/server/src/workflow/Services/ApprovalGate.ts b/apps/server/src/workflow/Services/ApprovalGate.ts new file mode 100644 index 00000000000..108a6e495c8 --- /dev/null +++ b/apps/server/src/workflow/Services/ApprovalGate.ts @@ -0,0 +1,12 @@ +import type { StepRunId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ApprovalGateShape { + readonly await: (stepRunId: StepRunId) => Effect.Effect; + readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect; +} + +export class ApprovalGate extends Context.Service()( + "t3/workflow/Services/ApprovalGate", +) {} From dff090b2fa0706cd519ee9a7dadbb8caf070dd56 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:17:29 -0400 Subject: [PATCH 024/295] feat(workflow): add WorkflowEngine state machine (create/run/route/drag/approve) Constraint: M2 keeps provider execution behind StepExecutor and in-flight pipeline fibers in process; durable dispatch and recovery are deferred to M3. Rejected: Implementing provider dispatch in the engine | Out of scope for M2 and would collapse the StepExecutor seam. Confidence: high Scope-risk: moderate Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEngine.integration.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full workflow suite and vp check run at M2 DoD. --- .../Layers/WorkflowEngine.integration.test.ts | 90 ++++++ .../src/workflow/Layers/WorkflowEngine.ts | 275 ++++++++++++++++++ .../server/src/workflow/Layers/WorkflowIds.ts | 5 +- .../src/workflow/Layers/WorkflowReadModel.ts | 2 + .../src/workflow/Services/WorkflowEngine.ts | 27 ++ .../workflow/Services/WorkflowReadModel.ts | 1 + 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.ts create mode 100644 apps/server/src/workflow/Services/WorkflowEngine.ts diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts new file mode 100644 index 00000000000..e96780910f3 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -0,0 +1,90 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import type { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +const definition = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const baseLayer = (executor: Layer.Layer) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const awaitLane = (ticketId: string, laneKey: string) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 20; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (detail?.ticket.currentLaneKey === laneKey) { + return detail; + } + yield* Effect.sleep("10 millis"); + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const successLayer = it.layer(baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }))); + +successLayer("WorkflowEngine integration", (it) => { + it.effect("auto lane runs the pipeline and routes to done", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-1" as never, + title: "Export", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + assert.equal( + detail?.steps.some((step) => step.status === "completed"), + true, + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts new file mode 100644 index 00000000000..5b393230183 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -0,0 +1,275 @@ +import type { + BoardId, + LaneEntryToken, + LaneKey, + PipelineRunId, + StepRunId, + TicketId, + WorkflowEventId, + WorkflowLane, + WorkflowStep, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import type { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import type { WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +type PipelineResult = "success" | "failure" | "blocked"; +type StepResult = "completed" | "failed" | "blocked"; +type MoveReason = "manual" | "routed" | "initial"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const make = Effect.gen(function* () { + const approvals = yield* ApprovalGate; + const committer = yield* WorkflowEventCommitter; + const executor = yield* StepExecutor; + const ids = yield* WorkflowIds; + const read = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + + const commit = ( + event: Omit, + ): Effect.Effect => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + yield* committer.commit({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + }); + + const currentToken = (ticketId: TicketId) => + read + .getTicketDetail(ticketId) + .pipe(Effect.map((detail) => detail?.ticket.currentLaneEntryToken ?? null)); + + const runStep = ( + ticketId: TicketId, + boardId: BoardId, + pipelineRunId: PipelineRunId, + step: WorkflowStep, + laneEntryToken: LaneEntryToken, + ): Effect.Effect => + Effect.gen(function* () { + const stepRunId = yield* ids.stepRunId(); + yield* commit({ + type: "StepStarted", + ticketId, + payload: { pipelineRunId, stepRunId, stepKey: step.key, stepType: step.type }, + }); + + if (step.type === "approval") { + yield* commit({ + type: "StepAwaitingUser", + ticketId, + payload: { stepRunId, waitingReason: step.prompt ?? "Approval required" }, + }); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId, + payload: { stepRunId, error: "rejected" }, + }); + return "failed"; + } + yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); + return "completed"; + } + + const outcome = yield* executor.execute({ + ticketId, + boardId, + pipelineRunId, + stepRunId, + laneEntryToken, + step, + }); + if (outcome._tag === "awaiting_user") { + yield* commit({ + type: "StepAwaitingUser", + ticketId, + payload: { stepRunId, waitingReason: outcome.waitingReason }, + }); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId, + payload: { stepRunId, error: "rejected" }, + }); + return "failed"; + } + yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); + return "completed"; + } + if (outcome._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId, + payload: { stepRunId, error: outcome.error }, + }); + return "failed"; + } + + yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); + return "completed"; + }); + + const runPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect => + Effect.gen(function* () { + const steps = lane.pipeline ?? []; + if (steps.length === 0) { + return; + } + + const pipelineRunId = yield* ids.pipelineRunId(); + yield* commit({ + type: "PipelineStarted", + ticketId, + payload: { pipelineRunId, laneKey: lane.key, laneEntryToken }, + }); + + let result: PipelineResult = "success"; + for (const step of steps) { + const stepResult = yield* runStep(ticketId, boardId, pipelineRunId, step, laneEntryToken); + if (stepResult === "failed") { + result = "failure"; + break; + } + if (stepResult === "blocked") { + result = "blocked"; + break; + } + } + + yield* commit({ + type: "PipelineCompleted", + ticketId, + payload: { pipelineRunId, result }, + }); + + const token = yield* currentToken(ticketId); + if (token !== laneEntryToken) { + return; + } + yield* route(ticketId, boardId, lane, result); + }).pipe(Effect.catch(() => Effect.void)); + + const route = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + result: PipelineResult, + ): Effect.Effect => + Effect.gen(function* () { + const target = lane.on?.[result]; + if (!target) { + if (result !== "success") { + yield* commit({ + type: "TicketBlocked", + ticketId, + payload: { reason: `pipeline ${result} with no route` }, + }); + } + return; + } + + yield* moveToLane(ticketId, boardId, target, "routed"); + }); + + const moveToLane = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + ): Effect.Effect => + Effect.gen(function* () { + const laneEntryToken = yield* ids.token(); + yield* commit({ + type: "TicketMovedToLane", + ticketId, + payload: { toLane, laneEntryToken, reason }, + }); + + const lane = yield* registry.getLane(boardId, toLane); + if (lane?.entry === "auto") { + yield* runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( + Effect.forkDetach({ startImmediately: true }), + ); + } + }); + + const createTicket: WorkflowEngineShape["createTicket"] = (input) => + Effect.gen(function* () { + const ticketId = yield* ids.ticketId(); + yield* commit({ + type: "TicketCreated", + ticketId, + payload: { + boardId: input.boardId, + title: input.title, + laneKey: input.initialLane, + description: input.description, + }, + } as Omit); + yield* moveToLane(ticketId, input.boardId, input.initialLane, "initial"); + return ticketId; + }); + + const moveTicket: WorkflowEngineShape["moveTicket"] = (ticketId, toLane) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (!detail) { + return; + } + yield* moveToLane(ticketId, detail.ticket.boardId as BoardId, toLane, "manual"); + }); + + const runLane: WorkflowEngineShape["runLane"] = (ticketId) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (!detail) { + return; + } + + const lane = yield* registry.getLane( + detail.ticket.boardId as BoardId, + detail.ticket.currentLaneKey as LaneKey, + ); + const token = yield* currentToken(ticketId); + if (lane && token) { + yield* runPipeline( + ticketId, + detail.ticket.boardId as BoardId, + lane, + token as LaneEntryToken, + ).pipe(Effect.forkDetach({ startImmediately: true })); + } + }); + + const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => + approvals.resolve(stepRunId, approved); + + return { createTicket, moveTicket, runLane, resolveApproval } satisfies WorkflowEngineShape; +}); + +export const WorkflowEngineLayer = Layer.effect(WorkflowEngine, make); diff --git a/apps/server/src/workflow/Layers/WorkflowIds.ts b/apps/server/src/workflow/Layers/WorkflowIds.ts index 996c839c003..6f19ad56d33 100644 --- a/apps/server/src/workflow/Layers/WorkflowIds.ts +++ b/apps/server/src/workflow/Layers/WorkflowIds.ts @@ -37,7 +37,10 @@ export const WorkflowIdsLive = Layer.effect( Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const next = (prefix: string) => - crypto.randomUUIDv4.pipe(Effect.map((uuid) => `${prefix}-${uuid}`)); + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => `${prefix}-${uuid}`), + ); return { ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts index 177d96baf45..64f513933e7 100644 --- a/apps/server/src/workflow/Layers/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -67,6 +67,7 @@ const make = Effect.gen(function* () { board_id AS "boardId", title, current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", status FROM projection_ticket WHERE board_id = ${boardId} @@ -81,6 +82,7 @@ const make = Effect.gen(function* () { board_id AS "boardId", title, current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", status FROM projection_ticket WHERE ticket_id = ${ticketId} diff --git a/apps/server/src/workflow/Services/WorkflowEngine.ts b/apps/server/src/workflow/Services/WorkflowEngine.ts new file mode 100644 index 00000000000..bb2bd49b95d --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEngine.ts @@ -0,0 +1,27 @@ +import type { BoardId, LaneKey, StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowEngineShape { + readonly createTicket: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string; + readonly initialLane: LaneKey; + }) => Effect.Effect; + readonly moveTicket: ( + ticketId: TicketId, + toLane: LaneKey, + ) => Effect.Effect; + readonly runLane: (ticketId: TicketId) => Effect.Effect; + readonly resolveApproval: ( + stepRunId: StepRunId, + approved: boolean, + ) => Effect.Effect; +} + +export class WorkflowEngine extends Context.Service()( + "t3/workflow/Services/WorkflowEngine", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts index c5c82da95db..666c5c73bcf 100644 --- a/apps/server/src/workflow/Services/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -18,6 +18,7 @@ export interface TicketRow { readonly boardId: string; readonly title: string; readonly currentLaneKey: string; + readonly currentLaneEntryToken: string | null; readonly status: string; } From 417f213a3ec7643c824a40db0f7d1da196639eba Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:21:39 -0400 Subject: [PATCH 025/295] test(workflow): integration tests for pipeline routing and approval gate Constraint: Task 8 is test coverage for the M2 in-process engine; durable provider execution remains deferred to M3. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEngine.integration.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full workflow suite and vp check run at M2 DoD. --- .../Layers/WorkflowEngine.integration.test.ts | 128 +++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index e96780910f3..784e338d26f 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -1,13 +1,14 @@ import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { BoardRegistry } from "../Services/BoardRegistry.ts"; -import type { StepExecutor } from "../Services/StepExecutor.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; -import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; import { ApprovalGateLive } from "./ApprovalGate.ts"; import { BoardRegistryLive } from "./BoardRegistry.ts"; @@ -52,11 +53,17 @@ const baseLayer = (executor: Layer.Layer) => ); const awaitLane = (ticketId: string, laneKey: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.currentLaneKey === laneKey); + +const awaitStatus = (ticketId: string, status: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.status === status); + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => Effect.gen(function* () { const read = yield* WorkflowReadModel; for (let attempt = 0; attempt < 20; attempt += 1) { const detail = yield* read.getTicketDetail(ticketId as never); - if (detail?.ticket.currentLaneKey === laneKey) { + if (predicate(detail)) { return detail; } yield* Effect.sleep("10 millis"); @@ -88,3 +95,118 @@ successLayer("WorkflowEngine integration", (it) => { }), ); }); + +const failLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "failed", error: "boom" } })), +); + +failLayer("WorkflowEngine integration failure path", (it) => { + it.effect("failed step routes to the failure lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-fail" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-fail" as never, + title: "Fix", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal( + detail?.steps.some((step) => step.status === "failed"), + true, + ); + }), + ); +}); + +const approvalDefinition = { + name: "approval-wf", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [{ key: "ok", type: "approval", prompt: "Approve?" }], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +successLayer("WorkflowEngine approval gate", (it) => { + it.effect("parks on approval then routes on approve", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-approval" as never, approvalDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-approval" as never, + title: "Approve me", + initialLane: "review" as never, + }); + + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + assert.equal(waitingDetail?.ticket.status, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + yield* engine.resolveApproval(stepRunId as never, true); + const doneDetail = yield* awaitLane(ticketId as string, "done"); + assert.equal(doneDetail?.ticket.currentLaneKey, "done"); + }), + ); +}); + +let supersedeStarted: Deferred.Deferred | undefined; +let supersedeRelease: Deferred.Deferred | undefined; + +const blockingSuccessExecutor = Layer.effect( + StepExecutor, + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + supersedeStarted = started; + supersedeRelease = release; + + return StepExecutor.of({ + execute: () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { _tag: "completed" as const }; + }), + } satisfies StepExecutorShape); + }), +); + +const supersedeLayer = it.layer(baseLayer(blockingSuccessExecutor)); + +supersedeLayer("WorkflowEngine manual move supersede", (it) => { + it.effect("manual move prevents a stale pipeline from routing the ticket", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-supersede" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-supersede" as never, + title: "Hold position", + initialLane: "impl" as never, + }); + assert.exists(supersedeStarted); + assert.exists(supersedeRelease); + yield* Deferred.await(supersedeStarted).pipe(Effect.timeout("1 second")); + yield* engine.moveTicket(ticketId, "needs" as never); + yield* Deferred.succeed(supersedeRelease, undefined); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); +}); From 963530742cc79777aae064bc4ccca23cbf009453 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:40:48 -0400 Subject: [PATCH 026/295] feat(workflow): per-board concurrency gate for running tickets Constraint: M2 concurrency is in-process and keyed by board definition settings; durable recovery remains M3. Rejected: Provider/worktree-level throttling | That belongs to the M3 StepExecutor implementation, not the engine core. Confidence: medium Scope-risk: moderate Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEngine.concurrency.test.ts Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEngine.integration.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full workflow suite and vp check run at M2 DoD. --- .../Layers/WorkflowEngine.concurrency.test.ts | 95 +++++++++++++++++++ .../Layers/WorkflowEngine.integration.test.ts | 1 + .../src/workflow/Layers/WorkflowEngine.ts | 53 +++++++++-- 3 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts new file mode 100644 index 00000000000..22adafecf58 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts @@ -0,0 +1,95 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const definition = { + name: "limited", + settings: { maxConcurrentTickets: 1 }, + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +let activeExecutions = 0; +let maxActiveExecutions = 0; + +const countingExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.gen(function* () { + activeExecutions += 1; + maxActiveExecutions = Math.max(maxActiveExecutions, activeExecutions); + yield* Effect.sleep("20 millis"); + activeExecutions -= 1; + return { _tag: "completed" as const }; + }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEngine concurrency", (it) => { + it.effect("caps simultaneously running tickets per board", () => + Effect.gen(function* () { + activeExecutions = 0; + maxActiveExecutions = 0; + + const registry = yield* BoardRegistry; + yield* registry.register("b-limit" as never, definition); + const engine = yield* WorkflowEngine; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-limit" as never, + title: "First", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-limit" as never, + title: "Second", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(maxActiveExecutions, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index 784e338d26f..cdbb9316c87 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -199,6 +199,7 @@ supersedeLayer("WorkflowEngine manual move supersede", (it) => { title: "Hold position", initialLane: "impl" as never, }); + yield* Effect.yieldNow; assert.exists(supersedeStarted); assert.exists(supersedeRelease); yield* Deferred.await(supersedeStarted).pipe(Effect.timeout("1 second")); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index 5b393230183..c61915a56c9 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -12,6 +12,8 @@ import type { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import { ApprovalGate } from "../Services/ApprovalGate.ts"; import { BoardRegistry } from "../Services/BoardRegistry.ts"; @@ -36,6 +38,24 @@ const make = Effect.gen(function* () { const ids = yield* WorkflowIds; const read = yield* WorkflowReadModel; const registry = yield* BoardRegistry; + const boardSemaphores = yield* SynchronizedRef.make>(new Map()); + + const semaphoreFor = (boardId: BoardId, permits: number) => + SynchronizedRef.modifyEffect(boardSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing) { + return Effect.succeed([existing, current] as const); + } + + return Semaphore.make(permits).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, semaphore); + return [semaphore, next] as const; + }), + ); + }); const commit = ( event: Omit, @@ -135,6 +155,19 @@ const make = Effect.gen(function* () { lane: WorkflowLane, laneEntryToken: LaneEntryToken, ): Effect.Effect => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + const permits = Math.max(1, definition?.settings?.maxConcurrentTickets ?? 3); + const semaphore = yield* semaphoreFor(boardId, permits); + yield* semaphore.withPermits(1)(runPipelineBody(ticketId, boardId, lane, laneEntryToken)); + }).pipe(Effect.catch(() => Effect.void)); + + const runPipelineBody = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect => Effect.gen(function* () { const steps = lane.pipeline ?? []; if (steps.length === 0) { @@ -172,7 +205,17 @@ const make = Effect.gen(function* () { return; } yield* route(ticketId, boardId, lane, result); - }).pipe(Effect.catch(() => Effect.void)); + }); + + const startPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ) => + runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( + Effect.forkDetach({ startImmediately: true }), + ); const route = ( ticketId: TicketId, @@ -212,9 +255,7 @@ const make = Effect.gen(function* () { const lane = yield* registry.getLane(boardId, toLane); if (lane?.entry === "auto") { - yield* runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( - Effect.forkDetach({ startImmediately: true }), - ); + yield* startPipeline(ticketId, boardId, lane, laneEntryToken); } }); @@ -257,12 +298,12 @@ const make = Effect.gen(function* () { ); const token = yield* currentToken(ticketId); if (lane && token) { - yield* runPipeline( + yield* startPipeline( ticketId, detail.ticket.boardId as BoardId, lane, token as LaneEntryToken, - ).pipe(Effect.forkDetach({ startImmediately: true })); + ); } }); From dd1f8cfbefd4ed95f86f6c8a69db66f202349a5c Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:42:32 -0400 Subject: [PATCH 027/295] feat(workflow): aggregate WorkflowEngine core layer Constraint: Core live wiring provides the M2 engine stack but still requires caller-provided StepExecutor and SQL runtime layers. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/WorkflowEngineLive.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full workflow suite and vp check run at M2 DoD. --- .../src/workflow/WorkflowEngineLive.test.ts | 62 +++++++++++++++++++ .../server/src/workflow/WorkflowEngineLive.ts | 16 +++++ 2 files changed, 78 insertions(+) create mode 100644 apps/server/src/workflow/WorkflowEngineLive.test.ts create mode 100644 apps/server/src/workflow/WorkflowEngineLive.ts diff --git a/apps/server/src/workflow/WorkflowEngineLive.test.ts b/apps/server/src/workflow/WorkflowEngineLive.test.ts new file mode 100644 index 00000000000..d466191d745 --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.test.ts @@ -0,0 +1,62 @@ +import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { makeStubStepExecutor } from "./Layers/StubStepExecutor.ts"; +import { BoardRegistry } from "./Services/BoardRegistry.ts"; +import { WorkflowEngine } from "./Services/WorkflowEngine.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowEngineCoreLive } from "./WorkflowEngineLive.ts"; + +const definition = { + name: "wf", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], +}; + +let cryptoByte = 0; +const TestCrypto = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => { + const bytes = new Uint8Array(size); + bytes.fill(cryptoByte); + cryptoByte = (cryptoByte + 1) % 256; + return bytes; + }, + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const layer = it.layer( + WorkflowEngineCoreLive.pipe( + Layer.provideMerge(makeStubStepExecutor({ default: { _tag: "completed" } })), + Layer.provideMerge(TestCrypto), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEngineCoreLive", (it) => { + it.effect("composes the engine core with an injected StepExecutor", () => + Effect.gen(function* () { + cryptoByte = 0; + const registry = yield* BoardRegistry; + yield* registry.register("b-live" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-live" as never, + title: "Live layer", + initialLane: "backlog" as never, + }); + const detail = yield* read.getTicketDetail(ticketId); + + assert.equal(detail?.ticket.title, "Live layer"); + assert.equal(detail?.ticket.currentLaneKey, "backlog"); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowEngineLive.ts b/apps/server/src/workflow/WorkflowEngineLive.ts new file mode 100644 index 00000000000..4adf6cf6beb --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.ts @@ -0,0 +1,16 @@ +import * as Layer from "effect/Layer"; + +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +export const WorkflowEngineCoreLive = WorkflowEngineLayer.pipe( + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowIdsLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowFoundationLive), +); From 68e59738d6f958d6a0e3da320db8a1365fd29647 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:43:47 -0400 Subject: [PATCH 028/295] chore(workflow): satisfy M2 formatting checks Constraint: M2 DoD requires vp check with no formatting or lint issues before advancing. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow Tested: pnpm exec vp run typecheck Tested: pnpm exec vp check --- apps/server/src/workflow/Layers/BoardRegistry.ts | 5 +---- apps/server/src/workflow/Layers/StubStepExecutor.ts | 3 +-- apps/server/src/workflow/Layers/WorkflowEngine.ts | 1 - apps/server/src/workflow/Services/BoardRegistry.ts | 5 +---- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/server/src/workflow/Layers/BoardRegistry.ts b/apps/server/src/workflow/Layers/BoardRegistry.ts index 43daff5a988..6306344c520 100644 --- a/apps/server/src/workflow/Layers/BoardRegistry.ts +++ b/apps/server/src/workflow/Layers/BoardRegistry.ts @@ -42,10 +42,7 @@ const make = Effect.gen(function* () { const getLane: BoardRegistryShape["getLane"] = (boardId, laneKey) => getDefinition(boardId).pipe( - Effect.map( - (definition) => - definition?.lanes.find((lane) => lane.key === laneKey) ?? null, - ), + Effect.map((definition) => definition?.lanes.find((lane) => lane.key === laneKey) ?? null), ); return { register, getDefinition, getLane } satisfies BoardRegistryShape; diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.ts b/apps/server/src/workflow/Layers/StubStepExecutor.ts index b09a8eb8025..f7f1e6d0cbf 100644 --- a/apps/server/src/workflow/Layers/StubStepExecutor.ts +++ b/apps/server/src/workflow/Layers/StubStepExecutor.ts @@ -11,6 +11,5 @@ export interface StubScript { export const makeStubStepExecutor = (script: StubScript): Layer.Layer => Layer.succeed(StepExecutor, { - execute: (ctx) => - Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), + execute: (ctx) => Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), } satisfies StepExecutorShape); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index c61915a56c9..4d5f39f767f 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -3,7 +3,6 @@ import type { LaneEntryToken, LaneKey, PipelineRunId, - StepRunId, TicketId, WorkflowEventId, WorkflowLane, diff --git a/apps/server/src/workflow/Services/BoardRegistry.ts b/apps/server/src/workflow/Services/BoardRegistry.ts index f7f847adf28..789397eabd9 100644 --- a/apps/server/src/workflow/Services/BoardRegistry.ts +++ b/apps/server/src/workflow/Services/BoardRegistry.ts @@ -14,10 +14,7 @@ export interface BoardRegistryShape { definition: unknown, ) => Effect.Effect; readonly getDefinition: (boardId: BoardId) => Effect.Effect; - readonly getLane: ( - boardId: BoardId, - laneKey: LaneKey, - ) => Effect.Effect; + readonly getLane: (boardId: BoardId, laneKey: LaneKey) => Effect.Effect; } export class BoardRegistry extends Context.Service()( From 4cda06995e9dd02869856d32074b1b4b386a743a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:45:02 -0400 Subject: [PATCH 029/295] feat(workflow): add lease, dispatch-outbox, and setup-run tables Constraint: M3 migrations follow the existing sequential migration registry; placeholder numbers resolved to 035-037. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorktreeLeaseService.migration.test.ts Not-tested: Full M3 suite runs at milestone DoD. --- apps/server/src/persistence/Migrations.ts | 6 +++++ .../Migrations/035_WorkflowLease.ts | 16 +++++++++++ .../Migrations/036_WorkflowDispatchOutbox.ts | 27 +++++++++++++++++++ .../Migrations/037_WorkflowSetupRun.ts | 17 ++++++++++++ .../WorktreeLeaseService.migration.test.ts | 23 ++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/035_WorkflowLease.ts create mode 100644 apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts create mode 100644 apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts create mode 100644 apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 091af9ab936..abd0723a1ee 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,9 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_WorkflowEvents.ts"; import Migration0034 from "./Migrations/034_WorkflowTicketToken.ts"; +import Migration0035 from "./Migrations/035_WorkflowLease.ts"; +import Migration0036 from "./Migrations/036_WorkflowDispatchOutbox.ts"; +import Migration0037 from "./Migrations/037_WorkflowSetupRun.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +96,9 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "WorkflowEvents", Migration0033], [34, "WorkflowTicketToken", Migration0034], + [35, "WorkflowLease", Migration0035], + [36, "WorkflowDispatchOutbox", Migration0036], + [37, "WorkflowSetupRun", Migration0037], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_WorkflowLease.ts b/apps/server/src/persistence/Migrations/035_WorkflowLease.ts new file mode 100644 index 00000000000..3317a351bab --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_WorkflowLease.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS worktree_lease ( + worktree_ref TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL, + owner_id TEXT NOT NULL, + fence_token INTEGER NOT NULL, + acquired_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts b/apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts new file mode 100644 index 00000000000..a1bcc99caf8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_dispatch_outbox ( + dispatch_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + step_run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + turn_id TEXT, + provider_instance TEXT NOT NULL, + model TEXT NOT NULL, + instruction TEXT NOT NULL, + worktree_path TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + confirmed_at TEXT + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_dispatch_outbox_pending + ON workflow_dispatch_outbox(status) + `; +}); diff --git a/apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts b/apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts new file mode 100644 index 00000000000..d9752a40a89 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_setup_run ( + setup_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL UNIQUE, + worktree_ref TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; +}); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts new file mode 100644 index 00000000000..ef087921a84 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts @@ -0,0 +1,23 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("M3 migrations", (it) => { + it.effect("creates lease, dispatch outbox, and setup run tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name IN ('worktree_lease', 'workflow_dispatch_outbox', 'workflow_setup_run') + `; + assert.equal(rows.length, 3); + }), + ); +}); From b5a6730895962587d213c82680c3de8bbf4350bf Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:46:34 -0400 Subject: [PATCH 030/295] feat(workflow): add fenced WorktreeLeaseService Constraint: Release invalidates stale writers by bumping the persisted fence token while retaining monotonicity for subsequent acquires. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorktreeLeaseService.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../Layers/WorktreeLeaseService.test.ts | 40 ++++++++ .../workflow/Layers/WorktreeLeaseService.ts | 96 +++++++++++++++++++ .../workflow/Services/WorktreeLeaseService.ts | 29 ++++++ 3 files changed, 165 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts create mode 100644 apps/server/src/workflow/Layers/WorktreeLeaseService.ts create mode 100644 apps/server/src/workflow/Services/WorktreeLeaseService.ts diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts new file mode 100644 index 00000000000..23454183f87 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts @@ -0,0 +1,40 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; + +const layer = it.layer( + WorktreeLeaseServiceLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorktreeLeaseService", (it) => { + it.effect("acquire returns a monotonically increasing fence token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-1", "step", "sr-1"); + yield* lease.release("wt-1", a.fenceToken); + const b = yield* lease.acquire("wt-1", "step", "sr-2"); + + assert.isAbove(b.fenceToken, a.fenceToken); + }), + ); + + it.effect("validate rejects a stale token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-2", "step", "sr-1"); + yield* lease.release("wt-2", a.fenceToken); + yield* lease.acquire("wt-2", "step", "sr-2"); + const valid = yield* lease.isValid("wt-2", a.fenceToken); + + assert.equal(valid, false); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.ts new file mode 100644 index 00000000000..a8da426c7d4 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.ts @@ -0,0 +1,96 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorktreeLeaseService, + type Lease, + type WorktreeLeaseServiceShape, +} from "../Services/WorktreeLeaseService.ts"; + +const leaseExpiresAt = (now: DateTime.Utc) => DateTime.add(now, { minutes: 30 }); + +const toLeaseError = (cause: unknown) => + new WorkflowEventStoreError({ message: "lease op failed", cause }); + +const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toLeaseError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const acquire: WorktreeLeaseServiceShape["acquire"] = (worktreeRef, ownerKind, ownerId) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const acquiredAt = DateTime.formatIso(now); + const expiresAt = DateTime.formatIso(leaseExpiresAt(now)); + const rows = yield* wrap(sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + ${worktreeRef}, + ${ownerKind}, + ${ownerId}, + COALESCE( + (SELECT fence_token FROM worktree_lease WHERE worktree_ref = ${worktreeRef}), + 0 + ) + 1, + ${acquiredAt}, + ${expiresAt} + ) + ON CONFLICT(worktree_ref) DO UPDATE SET + owner_kind = excluded.owner_kind, + owner_id = excluded.owner_id, + fence_token = worktree_lease.fence_token + 1, + acquired_at = excluded.acquired_at, + expires_at = excluded.expires_at + RETURNING fence_token AS "fenceToken" + `); + const lease = rows[0]; + if (!lease) { + return yield* new WorkflowEventStoreError({ message: "lease acquire returned no row" }); + } + return lease; + }); + + const release: WorktreeLeaseServiceShape["release"] = (worktreeRef, fenceToken) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap(sql` + UPDATE worktree_lease + SET owner_kind = 'released', + owner_id = '', + fence_token = fence_token + 1, + acquired_at = ${now}, + expires_at = ${now} + WHERE worktree_ref = ${worktreeRef} + AND fence_token = ${fenceToken} + `); + }).pipe(Effect.asVoid); + + const isValid: WorktreeLeaseServiceShape["isValid"] = (worktreeRef, fenceToken) => + wrap(sql<{ readonly fenceToken: number; readonly ownerKind: string }>` + SELECT + fence_token AS "fenceToken", + owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = ${worktreeRef} + `).pipe( + Effect.map((rows) => { + const row = rows[0]; + return row?.fenceToken === fenceToken && row.ownerKind !== "released"; + }), + ); + + return { acquire, release, isValid } satisfies WorktreeLeaseServiceShape; +}); + +export const WorktreeLeaseServiceLive = Layer.effect(WorktreeLeaseService, make); diff --git a/apps/server/src/workflow/Services/WorktreeLeaseService.ts b/apps/server/src/workflow/Services/WorktreeLeaseService.ts new file mode 100644 index 00000000000..caec92f1cd4 --- /dev/null +++ b/apps/server/src/workflow/Services/WorktreeLeaseService.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface Lease { + readonly fenceToken: number; +} + +export interface WorktreeLeaseServiceShape { + readonly acquire: ( + worktreeRef: string, + ownerKind: "step" | "user", + ownerId: string, + ) => Effect.Effect; + readonly release: ( + worktreeRef: string, + fenceToken: number, + ) => Effect.Effect; + readonly isValid: ( + worktreeRef: string, + fenceToken: number, + ) => Effect.Effect; +} + +export class WorktreeLeaseService extends Context.Service< + WorktreeLeaseService, + WorktreeLeaseServiceShape +>()("t3/workflow/Services/WorktreeLeaseService") {} From fb8f6bc9a9e23b6330cea9ab34235c981de2e2d8 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:50:26 -0400 Subject: [PATCH 031/295] feat(workflow): add durable SetupRunService gated on terminal exit Constraint: M3 Task 3 requires a stubbable setup terminal port plus a live bridge to ProjectSetupScriptRunner and TerminalManager. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/SetupRunService.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../workflow/Layers/SetupRunService.test.ts | 52 +++++ .../src/workflow/Layers/SetupRunService.ts | 178 ++++++++++++++++++ .../src/workflow/Services/SetupRunService.ts | 41 ++++ packages/contracts/src/workflow.ts | 3 + 4 files changed, 274 insertions(+) create mode 100644 apps/server/src/workflow/Layers/SetupRunService.test.ts create mode 100644 apps/server/src/workflow/Layers/SetupRunService.ts create mode 100644 apps/server/src/workflow/Services/SetupRunService.ts diff --git a/apps/server/src/workflow/Layers/SetupRunService.test.ts b/apps/server/src/workflow/Layers/SetupRunService.test.ts new file mode 100644 index 00000000000..ae2ce54b0b2 --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.test.ts @@ -0,0 +1,52 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { SetupRunService, SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { SetupRunServiceLive } from "./SetupRunService.ts"; + +const stubTerminal = (exitCode: number) => + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ terminalId: "term-1" }), + awaitExit: () => Effect.succeed({ exitCode }), + }); + +const layerForExit = (exitCode: number) => + it.layer( + SetupRunServiceLive.pipe( + Layer.provideMerge(stubTerminal(exitCode)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +layerForExit(0)("SetupRunService success", (it) => { + it.effect("completes on exit 0", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const sql = yield* SqlClient.SqlClient; + const result = yield* setup.runSetup("t-1" as never, "wt-1", "/tmp/wt-1", "setup-1" as never); + + assert.equal(result.status, "completed"); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_setup_run WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.status, "completed"); + }), + ); +}); + +layerForExit(1)("SetupRunService failure", (it) => { + it.effect("fails on non-zero exit", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const result = yield* setup.runSetup("t-2" as never, "wt-2", "/tmp/wt-2", "setup-2" as never); + + assert.equal(result.status, "failed"); + assert.equal(result.exitCode, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/SetupRunService.ts b/apps/server/src/workflow/Layers/SetupRunService.ts new file mode 100644 index 00000000000..27906379dda --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.ts @@ -0,0 +1,178 @@ +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, +} from "../../project/Services/ProjectSetupScriptRunner.ts"; +import { TerminalManager, type TerminalManagerShape } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + SetupRunService, + SetupTerminalPort, + type SetupRunServiceShape, + type SetupTerminalPortShape, + type SetupStatus, +} from "../Services/SetupRunService.ts"; + +const SETUP_TIMEOUT_MS = 10 * 60 * 1000; + +const toSetupError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toSetupError("setup op failed"))); + +interface SetupRunRow { + readonly status: string; + readonly exitCode: number | null; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const normalizeStatus = (exitCode: number): SetupStatus => + exitCode === 0 ? "completed" : exitCode === -1 ? "timed_out" : "failed"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const terminal = yield* SetupTerminalPort; + + const runSetup: SetupRunServiceShape["runSetup"] = ( + ticketId, + worktreeRef, + worktreePath, + setupRunId, + ) => + Effect.gen(function* () { + const existing = yield* wrapSql(sql` + SELECT + status, + exit_code AS "exitCode" + FROM workflow_setup_run + WHERE ticket_id = ${ticketId} + `); + if (existing[0]?.status === "completed") { + return { status: "completed", exitCode: existing[0].exitCode }; + } + + yield* wrapSql(sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${setupRunId}, ${ticketId}, ${worktreeRef}, 'running', ${yield* nowIso}) + ON CONFLICT(ticket_id) DO UPDATE SET + setup_run_id = excluded.setup_run_id, + worktree_ref = excluded.worktree_ref, + status = 'running', + started_at = excluded.started_at, + finished_at = NULL, + exit_code = NULL + `); + + const { terminalId } = yield* terminal.launch({ worktreePath }); + const exit = + terminalId === null + ? { exitCode: 0 } + : yield* terminal + .awaitExit({ terminalId, timeoutMs: SETUP_TIMEOUT_MS }) + .pipe(Effect.orElseSucceed(() => ({ exitCode: -1 }))); + const status = normalizeStatus(exit.exitCode); + + yield* wrapSql(sql` + UPDATE workflow_setup_run + SET status = ${status}, + exit_code = ${exit.exitCode}, + finished_at = ${yield* nowIso} + WHERE ticket_id = ${ticketId} + `); + + return { status, exitCode: exit.exitCode }; + }); + + return { runSetup } satisfies SetupRunServiceShape; +}); + +export const SetupRunServiceLive = Layer.effect(SetupRunService, make); + +const awaitTerminalExit = ( + terminals: TerminalManagerShape, + input: { readonly terminalId: string | null; readonly timeoutMs?: number }, +): Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError> => { + if (input.terminalId === null) { + return Effect.succeed({ exitCode: 0 }); + } + + return Effect.gen(function* () { + const done = yield* Deferred.make<{ readonly exitCode: number }>(); + const unsubscribe = yield* terminals.subscribe((event) => { + if (event.type !== "exited" || event.terminalId !== input.terminalId) { + return Effect.void; + } + return Deferred.succeed(done, { exitCode: event.exitCode ?? 1 }).pipe(Effect.asVoid); + }); + const wait = Deferred.await(done); + const timed = + input.timeoutMs === undefined + ? wait + : wait.pipe( + Effect.timeoutOption(Duration.millis(input.timeoutMs)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new WorkflowEventStoreError({ + message: "setup terminal wait timed out", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + return yield* timed.pipe( + Effect.mapError(toSetupError("setup terminal wait failed")), + Effect.ensuring(Effect.sync(unsubscribe)), + ); + }); +}; + +export const SetupTerminalPortLive = Layer.effect( + SetupTerminalPort, + Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner; + const terminals = yield* TerminalManager; + + return { + launch: (input) => { + const setupInput = { + threadId: input.threadId ?? `workflow-setup:${input.worktreePath}`, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + ...(input.preferredTerminalId === undefined + ? {} + : { preferredTerminalId: input.preferredTerminalId }), + } satisfies ProjectSetupScriptRunnerInput; + + return runner.runForThread(setupInput).pipe( + Effect.map((result) => + result.status === "no-script" + ? { terminalId: null } + : { terminalId: result.terminalId }, + ), + Effect.mapError(toSetupError("setup launch failed")), + ); + }, + awaitExit: (input) => awaitTerminalExit(terminals, input), + } satisfies SetupTerminalPortShape; + }), +); diff --git a/apps/server/src/workflow/Services/SetupRunService.ts b/apps/server/src/workflow/Services/SetupRunService.ts new file mode 100644 index 00000000000..73f4912b431 --- /dev/null +++ b/apps/server/src/workflow/Services/SetupRunService.ts @@ -0,0 +1,41 @@ +import type { SetupRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface SetupTerminalPortShape { + readonly launch: (input: { + readonly threadId?: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; + }) => Effect.Effect<{ readonly terminalId: string | null }, WorkflowEventStoreError>; + readonly awaitExit: (input: { + readonly terminalId: string | null; + readonly timeoutMs?: number; + }) => Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError>; +} + +export class SetupTerminalPort extends Context.Service()( + "t3/workflow/Services/SetupRunService/SetupTerminalPort", +) {} + +export type SetupStatus = "completed" | "failed" | "timed_out"; + +export interface SetupRunServiceShape { + readonly runSetup: ( + ticketId: TicketId, + worktreeRef: string, + worktreePath: string, + setupRunId: SetupRunId, + ) => Effect.Effect< + { readonly status: SetupStatus; readonly exitCode: number | null }, + WorkflowEventStoreError + >; +} + +export class SetupRunService extends Context.Service()( + "t3/workflow/Services/SetupRunService", +) {} diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 9269287314e..c2d18a20dc0 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -17,6 +17,9 @@ export type PipelineRunId = typeof PipelineRunId.Type; export const StepRunId = makeId("StepRunId"); export type StepRunId = typeof StepRunId.Type; +export const SetupRunId = makeId("SetupRunId"); +export type SetupRunId = typeof SetupRunId.Type; + export const LaneEntryToken = makeId("LaneEntryToken"); export type LaneEntryToken = typeof LaneEntryToken.Type; From 926478dc28d44d7491bba7959fe98417d60739bb Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:51:39 -0400 Subject: [PATCH 032/295] feat(workflow): add TurnStateReader (terminal state from projections) Constraint: M3 Task 4 reads terminal turn state from persisted projections through a stubbable port, not live provider streams. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/TurnStateReader.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../workflow/Layers/TurnStateReader.test.ts | 44 ++++++++++++++++ .../src/workflow/Layers/TurnStateReader.ts | 51 +++++++++++++++++++ .../src/workflow/Services/TurnStateReader.ts | 27 ++++++++++ 3 files changed, 122 insertions(+) create mode 100644 apps/server/src/workflow/Layers/TurnStateReader.test.ts create mode 100644 apps/server/src/workflow/Layers/TurnStateReader.ts create mode 100644 apps/server/src/workflow/Services/TurnStateReader.ts diff --git a/apps/server/src/workflow/Layers/TurnStateReader.test.ts b/apps/server/src/workflow/Layers/TurnStateReader.test.ts new file mode 100644 index 00000000000..3933773c434 --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { TurnProjectionPort, TurnStateReader } from "../Services/TurnStateReader.ts"; +import { TurnStateReaderLive } from "./TurnStateReader.ts"; + +const stub = (state: string) => + Layer.succeed(TurnProjectionPort, { + getLatestTurnState: () => + Effect.succeed({ state, completed: state === "completed" || state === "error" }), + }); + +const mk = (state: string) => it.layer(TurnStateReaderLive.pipe(Layer.provideMerge(stub(state)))); + +mk("completed")("TurnStateReader completed", (it) => { + it.effect("maps completed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "completed"); + }), + ); +}); + +mk("error")("TurnStateReader error", (it) => { + it.effect("maps error to failed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "failed"); + }), + ); +}); + +mk("running")("TurnStateReader running", (it) => { + it.effect("maps running", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "running"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TurnStateReader.ts b/apps/server/src/workflow/Layers/TurnStateReader.ts new file mode 100644 index 00000000000..d0a5be90229 --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.ts @@ -0,0 +1,51 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { + TurnProjectionPort, + TurnStateReader, + type TurnProjectionPortShape, + type TurnState, + type TurnStateReaderShape, +} from "../Services/TurnStateReader.ts"; + +const toTurnState = (state: string): TurnState => { + if (state === "completed") { + return { _tag: "completed" }; + } + if (state === "error" || state === "interrupted") { + return { _tag: "failed", error: state }; + } + return { _tag: "running" }; +}; + +const make = Effect.gen(function* () { + const port = yield* TurnProjectionPort; + + const read: TurnStateReaderShape["read"] = (threadId) => + port.getLatestTurnState(threadId).pipe(Effect.map(({ state }) => toTurnState(state))); + + return { read } satisfies TurnStateReaderShape; +}); + +export const TurnStateReaderLive = Layer.effect(TurnStateReader, make); + +export const TurnProjectionPortLive = Layer.effect( + TurnProjectionPort, + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + + const getLatestTurnState: TurnProjectionPortShape["getLatestTurnState"] = (threadId) => + turns.listByThreadId({ threadId }).pipe( + Effect.map((rows) => rows.at(-1)), + Effect.map((turn) => ({ + state: turn?.state ?? "pending", + completed: turn?.state === "completed" || turn?.state === "error", + })), + Effect.orElseSucceed(() => ({ state: "pending", completed: false })), + ); + + return { getLatestTurnState } satisfies TurnProjectionPortShape; + }), +); diff --git a/apps/server/src/workflow/Services/TurnStateReader.ts b/apps/server/src/workflow/Services/TurnStateReader.ts new file mode 100644 index 00000000000..431eb021a82 --- /dev/null +++ b/apps/server/src/workflow/Services/TurnStateReader.ts @@ -0,0 +1,27 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export type TurnState = + | { readonly _tag: "running" } + | { readonly _tag: "completed" } + | { readonly _tag: "failed"; readonly error: string }; + +export interface TurnProjectionPortShape { + readonly getLatestTurnState: ( + threadId: ThreadId, + ) => Effect.Effect<{ readonly state: string; readonly completed: boolean }>; +} + +export class TurnProjectionPort extends Context.Service< + TurnProjectionPort, + TurnProjectionPortShape +>()("t3/workflow/Services/TurnStateReader/TurnProjectionPort") {} + +export interface TurnStateReaderShape { + readonly read: (threadId: ThreadId) => Effect.Effect; +} + +export class TurnStateReader extends Context.Service()( + "t3/workflow/Services/TurnStateReader", +) {} From 8b807bc042ba1b97a382bd75064103e764a45c2f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:56:50 -0400 Subject: [PATCH 033/295] feat(workflow): add durable provider-dispatch outbox Constraint: M3 Task 5 requires durable idempotent dispatch with stubbable provider turns and terminal confirmation from persisted projections. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/ProviderDispatchOutbox.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../Layers/ProviderDispatchOutbox.test.ts | 89 ++++++++ .../workflow/Layers/ProviderDispatchOutbox.ts | 190 ++++++++++++++++++ .../Services/ProviderDispatchOutbox.ts | 40 ++++ packages/contracts/src/workflow.ts | 3 + 4 files changed, 322 insertions(+) create mode 100644 apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts create mode 100644 apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts create mode 100644 apps/server/src/workflow/Services/ProviderDispatchOutbox.ts diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts new file mode 100644 index 00000000000..5ed9e48a6f6 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts @@ -0,0 +1,89 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; + +const request = { + dispatchId: "dispatch-1" as never, + ticketId: "ticket-1" as never, + stepRunId: "step-run-1" as never, + threadId: "thread-1" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "Implement the next workflow step", + worktreePath: "/tmp/workflow-ticket-1", +} satisfies DispatchRequest; + +it.effect("starts provider dispatch idempotently and confirms from terminal turn state", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make(0); + const turnReads = yield* Ref.make(0); + + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => + Ref.update(providerCalls, (count) => count + 1).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => + Ref.updateAndGet(turnReads, (count) => count + 1).pipe( + Effect.map((count) => + count === 1 ? ({ _tag: "running" } as const) : ({ _tag: "completed" } as const), + ), + ), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* outbox.ensureStarted(request); + yield* outbox.ensureStarted(request); + + assert.equal(yield* Ref.get(providerCalls), 1); + + const started = yield* sql<{ readonly status: string; readonly turnId: string | null }>` + SELECT status, turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(started[0]?.status, "started"); + assert.equal(started[0]?.turnId, "turn-1"); + + const terminalFiber = yield* Effect.forkChild( + outbox.awaitTerminal(request.dispatchId, request.threadId), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const terminal = yield* Fiber.join(terminalFiber); + assert.deepEqual(terminal, { ok: true }); + + const confirmed = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(confirmed[0]?.status, "confirmed"); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts new file mode 100644 index 00000000000..315961f0586 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts @@ -0,0 +1,190 @@ +import { + ProviderInstanceId, + TrimmedNonEmptyString, + type ProviderSendTurnInput, + type ProviderSessionStartInput, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, + type ProviderDispatchOutboxShape, + type ProviderTurnPortShape, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const toDispatchError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toDispatchError("dispatch op failed"))); + +interface DispatchStatusRow { + readonly status: "pending" | "started" | "confirmed"; +} + +interface RecoverDispatchRow extends DispatchRequest { + readonly status: "pending" | "started" | "confirmed"; +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const provider = yield* ProviderTurnPort; + const turns = yield* TurnStateReader; + + const getStatus = (dispatchId: string) => + wrapSql(sql` + SELECT status + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${dispatchId} + `).pipe(Effect.map((rows) => rows[0]?.status ?? null)); + + const ensureStarted: ProviderDispatchOutboxShape["ensureStarted"] = (req) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + yield* wrapSql(sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + ${req.dispatchId}, + ${req.ticketId}, + ${req.stepRunId}, + ${req.threadId}, + ${req.providerInstance}, + ${req.model}, + ${req.instruction}, + ${req.worktreePath}, + 'pending', + ${createdAt} + ) + ON CONFLICT(dispatch_id) DO NOTHING + `); + + const status = yield* getStatus(req.dispatchId); + if (status === "started" || status === "confirmed") { + return; + } + + const { turnId } = yield* provider.ensureTurnStarted(req); + const startedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'started', + turn_id = ${turnId}, + started_at = ${startedAt} + WHERE dispatch_id = ${req.dispatchId} + `); + }); + + const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => + Effect.gen(function* () { + let state = yield* turns.read(threadId); + while (state._tag === "running") { + yield* Effect.sleep("500 millis"); + state = yield* turns.read(threadId); + } + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE dispatch_id = ${dispatchId} + `); + + return state._tag === "completed" + ? { ok: true } + : { ok: false, error: state._tag === "failed" ? state.error : "unknown" }; + }); + + const recoverPending: ProviderDispatchOutboxShape["recoverPending"] = () => + Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + provider_instance AS "providerInstance", + model, + instruction, + worktree_path AS "worktreePath", + status + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + + yield* Effect.forEach( + rows, + (row) => (row.status === "pending" ? ensureStarted(row) : Effect.void), + { discard: true }, + ); + }); + + return { ensureStarted, awaitTerminal, recoverPending } satisfies ProviderDispatchOutboxShape; +}); + +export const ProviderDispatchOutboxLive = Layer.effect(ProviderDispatchOutbox, make); + +export const ProviderTurnPortLive = Layer.effect( + ProviderTurnPort, + Effect.gen(function* () { + const providerSvc = yield* ProviderService; + const turns = yield* ProjectionTurnRepository; + + const ensureTurnStarted: ProviderTurnPortShape["ensureTurnStarted"] = (req) => + Effect.gen(function* () { + const existingTurns = yield* turns + .listByThreadId({ threadId: req.threadId }) + .pipe(Effect.orElseSucceed(() => [])); + const existingTurn = existingTurns.findLast((turn) => turn.turnId !== null); + if (existingTurn?.turnId !== undefined && existingTurn.turnId !== null) { + return { turnId: existingTurn.turnId }; + } + + const providerInstanceId = ProviderInstanceId.make(req.providerInstance); + const modelSelection = { + instanceId: providerInstanceId, + model: TrimmedNonEmptyString.make(req.model), + }; + const sessionInput = { + threadId: req.threadId, + providerInstanceId, + cwd: TrimmedNonEmptyString.make(req.worktreePath), + modelSelection, + runtimeMode: "full-access", + } satisfies ProviderSessionStartInput; + const sendInput = { + threadId: req.threadId, + input: TrimmedNonEmptyString.make(req.instruction), + modelSelection, + } satisfies ProviderSendTurnInput; + + yield* providerSvc.startSession(req.threadId, sessionInput); + const turn = yield* providerSvc.sendTurn(sendInput); + return { turnId: turn.turnId }; + }).pipe(Effect.mapError(toDispatchError("provider start failed"))); + + return { ensureTurnStarted } satisfies ProviderTurnPortShape; + }), +); diff --git a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts new file mode 100644 index 00000000000..b2c4fa70603 --- /dev/null +++ b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts @@ -0,0 +1,40 @@ +import type { DispatchId, StepRunId, ThreadId, TicketId, TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DispatchRequest { + readonly dispatchId: DispatchId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: ThreadId; + readonly providerInstance: string; + readonly model: string; + readonly instruction: string; + readonly worktreePath: string; +} + +export interface ProviderTurnPortShape { + readonly ensureTurnStarted: ( + req: DispatchRequest, + ) => Effect.Effect<{ readonly turnId: TurnId }, WorkflowEventStoreError>; +} + +export class ProviderTurnPort extends Context.Service()( + "t3/workflow/Services/ProviderDispatchOutbox/ProviderTurnPort", +) {} + +export interface ProviderDispatchOutboxShape { + readonly ensureStarted: (req: DispatchRequest) => Effect.Effect; + readonly awaitTerminal: ( + dispatchId: DispatchId, + threadId: ThreadId, + ) => Effect.Effect<{ readonly ok: boolean; readonly error?: string }, WorkflowEventStoreError>; + readonly recoverPending: () => Effect.Effect; +} + +export class ProviderDispatchOutbox extends Context.Service< + ProviderDispatchOutbox, + ProviderDispatchOutboxShape +>()("t3/workflow/Services/ProviderDispatchOutbox") {} diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index c2d18a20dc0..55f69b992a2 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -20,6 +20,9 @@ export type StepRunId = typeof StepRunId.Type; export const SetupRunId = makeId("SetupRunId"); export type SetupRunId = typeof SetupRunId.Type; +export const DispatchId = makeId("DispatchId"); +export type DispatchId = typeof DispatchId.Type; + export const LaneEntryToken = makeId("LaneEntryToken"); export type LaneEntryToken = typeof LaneEntryToken.Type; From 60f544050a2b99ee2aa1b5b02a7cffc6efe640d2 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 02:59:21 -0400 Subject: [PATCH 034/295] feat(workflow): add RealStepExecutor (worktree+lease+setup+dispatch) Constraint: M3 Task 6 composes stubbable worktree/setup/dispatch ports with the real fenced lease and releases the lease in dispatch finalization. Rejected: Delete lease rows on release | release rows are retained to preserve monotonic fence tokens from Task 2. Confidence: high Scope-risk: moderate Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/RealStepExecutor.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../workflow/Layers/RealStepExecutor.test.ts | 91 ++++++++++++++ .../src/workflow/Layers/RealStepExecutor.ts | 116 ++++++++++++++++++ .../src/workflow/Services/WorktreePort.ts | 20 +++ 3 files changed, 227 insertions(+) create mode 100644 apps/server/src/workflow/Layers/RealStepExecutor.test.ts create mode 100644 apps/server/src/workflow/Layers/RealStepExecutor.ts create mode 100644 apps/server/src/workflow/Services/WorktreePort.ts diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts new file mode 100644 index 00000000000..adad933617d --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -0,0 +1,91 @@ +import { assert, it } from "@effect/vitest"; +import type { StepExecutionContext } from "../Services/StepExecutor.ts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; +import { RealStepExecutorLive } from "./RealStepExecutor.ts"; + +const context: StepExecutionContext = { + ticketId: "ticket-1" as never, + boardId: "board-1" as never, + pipelineRunId: "pipeline-run-1" as never, + stepRunId: "step-run-1" as never, + laneEntryToken: "lane-token-1" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket", + }, +}; + +const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => + it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ worktreeRef: "wt-ticket-1", path: "/tmp/wt-ticket-1" }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + ensureStarted: () => Effect.void, + awaitTerminal: () => Effect.succeed(terminal), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +mk({ ok: true })("RealStepExecutor success", (it) => { + it.effect("completes an agent step and releases the worktree lease", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + + const outcome = yield* executor.execute(context); + + assert.equal(outcome._tag, "completed"); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); +}); + +mk({ ok: false, error: "provider failed" })("RealStepExecutor failure", (it) => { + it.effect("fails an agent step when provider dispatch fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { _tag: "failed", error: "provider failed" }); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts new file mode 100644 index 00000000000..6673cdc78b8 --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -0,0 +1,116 @@ +import { TrimmedNonEmptyString, type StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { + WorktreePort, + type WorktreeHandle, + type WorktreePortShape, +} from "../Services/WorktreePort.ts"; + +const toExecutorError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const make = Effect.gen(function* () { + const worktrees = yield* WorktreePort; + const lease = yield* WorktreeLeaseService; + const setup = yield* SetupRunService; + const dispatch = yield* ProviderDispatchOutbox; + const ids = yield* WorkflowIds; + + const execute: StepExecutorShape["execute"] = (ctx) => + Effect.gen(function* () { + const step = ctx.step; + if (step.type !== "agent") { + return { _tag: "completed" } satisfies StepOutcome; + } + + const worktree = yield* worktrees.ensureWorktree(ctx.ticketId); + const setupRunId = yield* ids.eventId(); + const setupResult = yield* setup.runSetup( + ctx.ticketId, + worktree.worktreeRef, + worktree.path, + setupRunId as never, + ); + if (setupResult.status !== "completed") { + return { _tag: "failed", error: `setup ${setupResult.status}` } satisfies StepOutcome; + } + + const acquired = yield* lease.acquire(worktree.worktreeRef, "step", ctx.stepRunId as string); + const dispatchId = yield* ids.eventId(); + const threadId = yield* ids.eventId(); + const instruction = + typeof step.instruction === "string" ? step.instruction : `@@file:${step.instruction.file}`; + + const releaseIfStillOwner = lease.isValid(worktree.worktreeRef, acquired.fenceToken).pipe( + Effect.flatMap((valid) => + valid ? lease.release(worktree.worktreeRef, acquired.fenceToken) : Effect.void, + ), + Effect.orElseSucceed(() => undefined), + ); + + const result = yield* Effect.gen(function* () { + yield* dispatch.ensureStarted({ + dispatchId: dispatchId as never, + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + threadId: threadId as never, + providerInstance: step.agent.instance as string, + model: step.agent.model as string, + instruction, + worktreePath: worktree.path, + }); + return yield* dispatch.awaitTerminal(dispatchId as never, threadId as never); + }).pipe(Effect.ensuring(releaseIfStillOwner)); + + return result.ok + ? ({ _tag: "completed" } satisfies StepOutcome) + : ({ + _tag: "failed", + error: result.error ?? "turn failed", + } satisfies StepOutcome); + }).pipe( + Effect.orElseSucceed( + () => ({ _tag: "failed", error: "executor error" }) satisfies StepOutcome, + ), + ); + + return { execute } satisfies StepExecutorShape; +}); + +export const RealStepExecutorLive = Layer.effect(StepExecutor, make); + +export const WorktreePortLive = Layer.effect( + WorktreePort, + Effect.gen(function* () { + const git = yield* GitWorkflowService; + + const ensureWorktree: WorktreePortShape["ensureWorktree"] = (ticketId) => + git + .createWorktree({ + cwd: TrimmedNonEmptyString.make(process.cwd()), + refName: TrimmedNonEmptyString.make("HEAD"), + newRefName: TrimmedNonEmptyString.make(`workflow/${ticketId}`), + path: null, + }) + .pipe( + Effect.map( + (result): WorktreeHandle => ({ + worktreeRef: result.worktree.refName, + path: result.worktree.path, + }), + ), + Effect.mapError(toExecutorError("worktree creation failed")), + ); + + return { ensureWorktree } satisfies WorktreePortShape; + }), +); diff --git a/apps/server/src/workflow/Services/WorktreePort.ts b/apps/server/src/workflow/Services/WorktreePort.ts new file mode 100644 index 00000000000..07d5e7cadee --- /dev/null +++ b/apps/server/src/workflow/Services/WorktreePort.ts @@ -0,0 +1,20 @@ +import type { TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeHandle { + readonly worktreeRef: string; + readonly path: string; +} + +export interface WorktreePortShape { + readonly ensureWorktree: ( + ticketId: TicketId, + ) => Effect.Effect; +} + +export class WorktreePort extends Context.Service()( + "t3/workflow/Services/WorktreePort", +) {} From 5282aa1349453fd07fca6a47accba2c8c2604a0e Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:03:42 -0400 Subject: [PATCH 035/295] feat(workflow): durable approvals + provider-question response wiring Constraint: Provider responses require persisted thread/request metadata, so StepAwaitingUser carries optional provider fields. Rejected: Respond with only stepRunId and approval boolean | ProviderService requires requestId for both user-input and request approvals. Confidence: high Scope-risk: moderate Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/DurableApprovalResume.test.ts src/workflow/Layers/ApprovalGate.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../src/workflow/Layers/ApprovalGate.ts | 1 + .../Layers/DurableApprovalResume.test.ts | 126 ++++++++++++++++++ .../workflow/Layers/DurableApprovalResume.ts | 72 ++++++++++ .../workflow/Layers/ProviderResponsePort.ts | 38 ++++++ .../src/workflow/Layers/WorkflowEngine.ts | 66 ++++++++- .../src/workflow/Services/ApprovalGate.ts | 1 + .../Services/DurableApprovalResume.ts | 13 ++ .../workflow/Services/ProviderResponsePort.ts | 24 ++++ packages/contracts/src/workflow.ts | 10 +- 9 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/workflow/Layers/DurableApprovalResume.test.ts create mode 100644 apps/server/src/workflow/Layers/DurableApprovalResume.ts create mode 100644 apps/server/src/workflow/Layers/ProviderResponsePort.ts create mode 100644 apps/server/src/workflow/Services/DurableApprovalResume.ts create mode 100644 apps/server/src/workflow/Services/ProviderResponsePort.ts diff --git a/apps/server/src/workflow/Layers/ApprovalGate.ts b/apps/server/src/workflow/Layers/ApprovalGate.ts index 879471ba12b..8085f0e240c 100644 --- a/apps/server/src/workflow/Layers/ApprovalGate.ts +++ b/apps/server/src/workflow/Layers/ApprovalGate.ts @@ -23,6 +23,7 @@ export const ApprovalGateLive = Layer.effect( }); return ApprovalGate.of({ + park: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.asVoid), await: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.flatMap(Deferred.await)), resolve: (stepRunId, approved) => getOrCreate(stepRunId).pipe( diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts new file mode 100644 index 00000000000..f3e30075861 --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts @@ -0,0 +1,126 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { DurableApprovalResumeLive } from "./DurableApprovalResume.ts"; + +const eventStoreLayer = WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +it.effect("parks unresolved workflow approval waits during recovery", () => + Effect.gen(function* () { + const parked = yield* Ref.make>([]); + const layer = DurableApprovalResumeLive.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.void, + park: (stepRunId) => + Ref.update(parked, (ids) => [...ids, stepRunId as string]).pipe(Effect.asVoid), + }), + ), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const resume = yield* DurableApprovalResume; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-await" as never, + ticketId: "ticket-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { stepRunId: "step-run-1" as never, waitingReason: "Approve?" }, + }); + + yield* resume.resume(); + + assert.deepEqual(yield* Ref.get(parked), ["step-run-1"]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("routes provider-question approval resolution to the provider response port", () => + Effect.gen(function* () { + const responses = yield* Ref.make>([]); + const layer = WorkflowEngineLayer.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (values) => [...values, input]), + }), + ), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.void, + park: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.succeed(WorkflowEventCommitter, { commit: () => Effect.void })), + Layer.provideMerge(Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") })), + Layer.provideMerge( + Layer.succeed(WorkflowReadModel, { + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + getDefinition: () => Effect.succeed(null), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const engine = yield* WorkflowEngine; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-await" as never, + ticketId: "ticket-provider" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider" as never, + waitingReason: "Provider needs approval", + providerThreadId: "thread-provider" as never, + providerRequestId: "request-provider" as never, + providerResponseKind: "request", + }, + }); + + yield* engine.resolveApproval("step-run-provider" as never, true); + + assert.deepEqual(yield* Ref.get(responses), [ + { + threadId: "thread-provider", + requestId: "request-provider", + responseKind: "request", + approved: true, + }, + ]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.ts new file mode 100644 index 00000000000..5aa7114bc8c --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.ts @@ -0,0 +1,72 @@ +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { + DurableApprovalResume, + type DurableApprovalResumeShape, +} from "../Services/DurableApprovalResume.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; + +type PendingWait = Extract; + +const toResumeError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toResumeError("approval resume sql failed"))); + +const pendingWaits = (events: ReadonlyArray) => { + const pending = new Map(); + + for (const event of events) { + if (event.type === "StepAwaitingUser") { + pending.set(event.payload.stepRunId as string, event); + continue; + } + if (event.type === "StepUserResolved") { + pending.delete(event.payload.stepRunId as string); + } + } + + return Array.from(pending.values()); +}; + +const make = Effect.gen(function* () { + const approvals = yield* ApprovalGate; + const store = yield* WorkflowEventStore; + const sql = yield* SqlClient.SqlClient; + + const resetProviderDispatch = (stepRunId: string) => + wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'pending', + started_at = NULL, + confirmed_at = NULL + WHERE step_run_id = ${stepRunId} + AND status != 'confirmed' + `); + + const resume: DurableApprovalResumeShape["resume"] = () => + Effect.gen(function* () { + const events = yield* Stream.runCollect(store.readAll()).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + for (const event of pendingWaits(events)) { + if (event.payload.providerThreadId && event.payload.providerRequestId) { + yield* resetProviderDispatch(event.payload.stepRunId as string); + } else { + yield* approvals.park(event.payload.stepRunId); + } + } + }); + + return { resume } satisfies DurableApprovalResumeShape; +}); + +export const DurableApprovalResumeLive = Layer.effect(DurableApprovalResume, make); diff --git a/apps/server/src/workflow/Layers/ProviderResponsePort.ts b/apps/server/src/workflow/Layers/ProviderResponsePort.ts new file mode 100644 index 00000000000..55e9ca8df3f --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderResponsePort.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderResponsePort, + type ProviderResponsePortShape, +} from "../Services/ProviderResponsePort.ts"; + +const toResponseError = (cause: unknown) => + new WorkflowEventStoreError({ message: "provider response failed", cause }); + +export const ProviderResponsePortLive = Layer.effect( + ProviderResponsePort, + Effect.gen(function* () { + const provider = yield* ProviderService; + + const respond: ProviderResponsePortShape["respond"] = (input) => + (input.responseKind === "request" + ? provider.respondToRequest({ + threadId: input.threadId, + requestId: input.requestId, + decision: input.approved ? "accept" : "decline", + }) + : provider.respondToUserInput({ + threadId: input.threadId, + requestId: input.requestId, + answers: { + approved: input.approved, + ...(input.text === undefined ? {} : { text: input.text }), + }, + }) + ).pipe(Effect.mapError(toResponseError)); + + return { respond } satisfies ProviderResponsePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index 4d5f39f767f..59830addce4 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -3,24 +3,33 @@ import type { LaneEntryToken, LaneKey, PipelineRunId, + StepRunId, TicketId, WorkflowEventId, WorkflowLane, WorkflowStep, } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as Stream from "effect/Stream"; import { ApprovalGate } from "../Services/ApprovalGate.ts"; import { BoardRegistry } from "../Services/BoardRegistry.ts"; import type { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; import { StepExecutor } from "../Services/StepExecutor.ts"; import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; -import type { WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventInput, +} from "../Services/WorkflowEventStore.ts"; import { WorkflowIds } from "../Services/WorkflowIds.ts"; import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; @@ -30,6 +39,8 @@ type MoveReason = "manual" | "routed" | "initial"; const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +type PendingWait = Extract; + const make = Effect.gen(function* () { const approvals = yield* ApprovalGate; const committer = yield* WorkflowEventCommitter; @@ -39,6 +50,39 @@ const make = Effect.gen(function* () { const registry = yield* BoardRegistry; const boardSemaphores = yield* SynchronizedRef.make>(new Map()); + const getOptionalServices = Effect.context().pipe( + Effect.map((context) => ({ + providerResponses: Context.getOption( + context as Context.Context, + ProviderResponsePort, + ), + store: Context.getOption(context as Context.Context, WorkflowEventStore), + })), + ); + + const pendingWaitFor = (stepRunId: StepRunId) => + Effect.gen(function* () { + const { store } = yield* getOptionalServices; + if (Option.isNone(store)) { + return null; + } + + const events = yield* Stream.runCollect(store.value.readAll()).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + let pending: PendingWait | null = null; + for (const event of events) { + if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { + pending = event; + continue; + } + if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { + pending = null; + } + } + return pending; + }); + const semaphoreFor = (boardId: BoardId, permits: number) => SynchronizedRef.modifyEffect(boardSemaphores, (current) => { const key = boardId as string; @@ -307,7 +351,25 @@ const make = Effect.gen(function* () { }); const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => - approvals.resolve(stepRunId, approved); + Effect.gen(function* () { + const pending = yield* pendingWaitFor(stepRunId); + const { providerResponses } = yield* getOptionalServices; + if ( + pending?.payload.providerThreadId && + pending.payload.providerRequestId && + pending.payload.providerResponseKind && + Option.isSome(providerResponses) + ) { + yield* providerResponses.value.respond({ + threadId: pending.payload.providerThreadId, + requestId: pending.payload.providerRequestId, + responseKind: pending.payload.providerResponseKind, + approved, + }); + } + + yield* approvals.resolve(stepRunId, approved); + }); return { createTicket, moveTicket, runLane, resolveApproval } satisfies WorkflowEngineShape; }); diff --git a/apps/server/src/workflow/Services/ApprovalGate.ts b/apps/server/src/workflow/Services/ApprovalGate.ts index 108a6e495c8..27cef81968c 100644 --- a/apps/server/src/workflow/Services/ApprovalGate.ts +++ b/apps/server/src/workflow/Services/ApprovalGate.ts @@ -3,6 +3,7 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; export interface ApprovalGateShape { + readonly park: (stepRunId: StepRunId) => Effect.Effect; readonly await: (stepRunId: StepRunId) => Effect.Effect; readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect; } diff --git a/apps/server/src/workflow/Services/DurableApprovalResume.ts b/apps/server/src/workflow/Services/DurableApprovalResume.ts new file mode 100644 index 00000000000..1b225f63997 --- /dev/null +++ b/apps/server/src/workflow/Services/DurableApprovalResume.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DurableApprovalResumeShape { + readonly resume: () => Effect.Effect; +} + +export class DurableApprovalResume extends Context.Service< + DurableApprovalResume, + DurableApprovalResumeShape +>()("t3/workflow/Services/DurableApprovalResume") {} diff --git a/apps/server/src/workflow/Services/ProviderResponsePort.ts b/apps/server/src/workflow/Services/ProviderResponsePort.ts new file mode 100644 index 00000000000..f6381a4d69f --- /dev/null +++ b/apps/server/src/workflow/Services/ProviderResponsePort.ts @@ -0,0 +1,24 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type ProviderResponseKind = "request" | "user-input"; + +export interface ProviderResponseInput { + readonly threadId: ThreadId; + readonly requestId: ApprovalRequestId; + readonly responseKind: ProviderResponseKind; + readonly approved: boolean; + readonly text?: string; +} + +export interface ProviderResponsePortShape { + readonly respond: (input: ProviderResponseInput) => Effect.Effect; +} + +export class ProviderResponsePort extends Context.Service< + ProviderResponsePort, + ProviderResponsePortShape +>()("t3/workflow/Services/ProviderResponsePort") {} diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 55f69b992a2..9775745ce4c 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ApprovalRequestId, IsoDateTime, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const makeId = (brand: Brand) => TrimmedNonEmptyString.pipe(Schema.brand(brand)); @@ -189,7 +189,13 @@ export const WorkflowEvent = Schema.Union([ Schema.Struct({ ...EventBase, type: Schema.Literal("StepAwaitingUser"), - payload: Schema.Struct({ stepRunId: StepRunId, waitingReason: Schema.String }), + payload: Schema.Struct({ + stepRunId: StepRunId, + waitingReason: Schema.String, + providerThreadId: Schema.optional(ThreadId), + providerRequestId: Schema.optional(ApprovalRequestId), + providerResponseKind: Schema.optional(Schema.Literals(["request", "user-input"])), + }), }), Schema.Struct({ ...EventBase, From 42c593694e7a6f3fe526e5a32ab15c975dac30d8 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:05:43 -0400 Subject: [PATCH 036/295] feat(workflow): add startup recovery reconciliation Constraint: Current engine exposes step terminal events and dispatch confirmation, but not full pipeline continuation/routing from recovery. Rejected: Re-run engine pipelines from recovery | no stable API exists yet to resume in the middle of a pipeline without duplicating engine internals. Confidence: high Scope-risk: moderate Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowRecovery.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../workflow/Layers/WorkflowRecovery.test.ts | 106 +++++++++++++ .../src/workflow/Layers/WorkflowRecovery.ts | 146 ++++++++++++++++++ .../src/workflow/Services/WorkflowRecovery.ts | 12 ++ 3 files changed, 264 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowRecovery.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRecovery.ts create mode 100644 apps/server/src/workflow/Services/WorkflowRecovery.ts diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts new file mode 100644 index 00000000000..eadcc4ca7bd --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts @@ -0,0 +1,106 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { DurableApprovalResumeLive } from "./DurableApprovalResume.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; +import { WorkflowRecoveryLive } from "./WorkflowRecovery.ts"; + +const layer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + eventId: () => Effect.succeed("evt-recovery-completed" as never), + token: () => Effect.succeed("token-unused" as never), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowRecovery", (it) => { + it.effect("confirms recovered dispatches and completes terminal steps", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const store = yield* WorkflowEventStore; + + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-1', + 'ticket-1', + 'step-run-1', + 'thread-1', + 'codex', + 'gpt-5.5', + 'finish the step', + '/tmp/wt-ticket-1', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + yield* recovery.recover(); + + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = 'dispatch-1' + `; + assert.equal(rows[0]?.status, "confirmed"); + + const events = yield* Stream.runCollect(store.readByTicket("ticket-1" as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal( + events.some( + (event) => event.type === "StepCompleted" && event.payload.stepRunId === "step-run-1", + ), + true, + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts new file mode 100644 index 00000000000..af8dc8cd0b8 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -0,0 +1,146 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import type { PersistedWorkflowEvent, WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowRecovery, type WorkflowRecoveryShape } from "../Services/WorkflowRecovery.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; + +interface DispatchRecoveryRow { + readonly dispatchId: string; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: string; +} + +interface LeaseRecoveryRow { + readonly worktreeRef: string; + readonly ownerId: string; + readonly fenceToken: number; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const toRecoveryError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toRecoveryError("workflow recovery sql failed"))); + +const isTerminalStepEvent = ( + event: PersistedWorkflowEvent, +): event is Extract => + event.type === "StepCompleted" || event.type === "StepFailed"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const outbox = yield* ProviderDispatchOutbox; + const approvals = yield* DurableApprovalResume; + const committer = yield* WorkflowEventCommitter; + const ids = yield* WorkflowIds; + const store = yield* WorkflowEventStore; + const leases = yield* WorktreeLeaseService; + + const ticketEvents = (ticketId: TicketId) => + Stream.runCollect(store.readByTicket(ticketId)).pipe(Effect.map((chunk) => Array.from(chunk))); + + const hasTerminalStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => events.some((event) => isTerminalStepEvent(event) && event.payload.stepRunId === stepRunId); + + const commitTerminalStep = ( + row: DispatchRecoveryRow, + result: { readonly ok: boolean; readonly error?: string }, + ) => + Effect.gen(function* () { + const events = yield* ticketEvents(row.ticketId); + if (hasTerminalStepEvent(events, row.stepRunId)) { + return; + } + + const eventId = yield* ids.eventId(); + const occurredAt = yield* nowIso; + const event = result.ok + ? ({ + type: "StepCompleted", + eventId, + ticketId: row.ticketId, + occurredAt, + payload: { stepRunId: row.stepRunId }, + } satisfies WorkflowEventInput) + : ({ + type: "StepFailed", + eventId, + ticketId: row.ticketId, + occurredAt, + payload: { stepRunId: row.stepRunId, error: result.error ?? "turn failed" }, + } satisfies WorkflowEventInput); + yield* committer.commit(event); + }); + + const recoverTerminalDispatches = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId" + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + + for (const row of rows) { + const result = yield* outbox.awaitTerminal(row.dispatchId as never, row.threadId as never); + yield* commitTerminalStep(row, result); + } + }); + + const releaseTerminalStepLeases = Effect.gen(function* () { + const events = yield* Stream.runCollect(store.readAll()).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const terminalStepRunIds = new Set( + events.filter(isTerminalStepEvent).map((event) => event.payload.stepRunId as string), + ); + if (terminalStepRunIds.size === 0) { + return; + } + + const rows = yield* wrapSql(sql` + SELECT + worktree_ref AS "worktreeRef", + owner_id AS "ownerId", + fence_token AS "fenceToken" + FROM worktree_lease + WHERE owner_kind = 'step' + `); + for (const row of rows) { + if (terminalStepRunIds.has(row.ownerId)) { + yield* leases.release(row.worktreeRef, row.fenceToken); + } + } + }); + + const recover: WorkflowRecoveryShape["recover"] = () => + Effect.gen(function* () { + yield* outbox.recoverPending(); + yield* recoverTerminalDispatches; + yield* approvals.resume(); + yield* releaseTerminalStepLeases; + }); + + return { recover } satisfies WorkflowRecoveryShape; +}); + +export const WorkflowRecoveryLive = Layer.effect(WorkflowRecovery, make); diff --git a/apps/server/src/workflow/Services/WorkflowRecovery.ts b/apps/server/src/workflow/Services/WorkflowRecovery.ts new file mode 100644 index 00000000000..269de5ba9d9 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowRecovery.ts @@ -0,0 +1,12 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowRecoveryShape { + readonly recover: () => Effect.Effect; +} + +export class WorkflowRecovery extends Context.Service()( + "t3/workflow/Services/WorkflowRecovery", +) {} From abb4fe7f94d2404a85c64a34d85677bb6b653da5 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:09:53 -0400 Subject: [PATCH 037/295] feat(workflow): mock ACP provider + e2e runtime with restart recovery Constraint: Runtime core keeps external ports injectable for deterministic tests; production WorkflowRuntimeLive supplies real bridges. Confidence: high Scope-risk: moderate Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowRuntime.integration.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M3 suite runs at milestone DoD. --- .../src/workflow/Layers/MockAcpProvider.ts | 94 ++++++++ .../WorkflowRuntime.integration.test.ts | 212 ++++++++++++++++++ .../src/workflow/WorkflowRuntimeLive.ts | 44 ++++ 3 files changed, 350 insertions(+) create mode 100644 apps/server/src/workflow/Layers/MockAcpProvider.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts create mode 100644 apps/server/src/workflow/WorkflowRuntimeLive.ts diff --git a/apps/server/src/workflow/Layers/MockAcpProvider.ts b/apps/server/src/workflow/Layers/MockAcpProvider.ts new file mode 100644 index 00000000000..4a44ed32c67 --- /dev/null +++ b/apps/server/src/workflow/Layers/MockAcpProvider.ts @@ -0,0 +1,94 @@ +import type { TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnProjectionPort } from "../Services/TurnStateReader.ts"; + +type MockTurnState = "running" | "completed" | "error"; + +interface MockTurn { + readonly threadId: string; + readonly turnId: TurnId; + readonly state: MockTurnState; +} + +interface MockAcpState { + readonly startedCount: number; + readonly turns: ReadonlyMap; +} + +export interface MockAcpProviderShape { + readonly startedCount: Effect.Effect; + readonly completeAllRunning: () => Effect.Effect; +} + +export class MockAcpProvider extends Context.Service()( + "t3/workflow/Layers/MockAcpProvider", +) {} + +export const MockAcpProviderLive = Layer.unwrap( + Effect.gen(function* () { + const state = yield* Ref.make({ + startedCount: 0, + turns: new Map(), + }); + + const providerTurnPort = ProviderTurnPort.of({ + ensureTurnStarted: (request) => + Ref.modify(state, (current) => { + const existing = current.turns.get(request.threadId as string); + if (existing) { + return [{ turnId: existing.turnId }, current] as const; + } + + const turn = { + threadId: request.threadId as string, + turnId: `turn-${request.threadId}` as TurnId, + state: "running" as const, + } satisfies MockTurn; + const turns = new Map(current.turns); + turns.set(turn.threadId, turn); + return [ + { turnId: turn.turnId }, + { startedCount: current.startedCount + 1, turns }, + ] as const; + }), + }); + + const turnProjectionPort = TurnProjectionPort.of({ + getLatestTurnState: (threadId) => + Ref.get(state).pipe( + Effect.map((current) => { + const turn = current.turns.get(threadId as string); + return { + state: turn?.state ?? "pending", + completed: turn?.state === "completed" || turn?.state === "error", + }; + }), + ), + }); + + const mock = MockAcpProvider.of({ + startedCount: Ref.get(state).pipe(Effect.map((current) => current.startedCount)), + completeAllRunning: () => + Ref.update(state, (current) => { + const turns = new Map(current.turns); + for (const [threadId, turn] of turns) { + if (turn.state === "running") { + turns.set(threadId, { ...turn, state: "completed" }); + } + } + return { ...current, turns }; + }), + }); + + return Layer.mergeAll( + Layer.succeed(MockAcpProvider, mock), + Layer.succeed(ProviderTurnPort, providerTurnPort), + Layer.succeed(TurnProjectionPort, turnProjectionPort), + ); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts new file mode 100644 index 00000000000..f8409f88a06 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts @@ -0,0 +1,212 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { MockAcpProvider, MockAcpProviderLive } from "./MockAcpProvider.ts"; +import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; + +const definition = { + name: "runtime-wf", + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "code-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Write the code", + }, + ], + on: { success: "review", failure: "code" }, + }, + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "review-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the code", + }, + ], + on: { success: "done", failure: "code" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const runtimeLayer = it.layer( + WorkflowRuntimeCoreLive.pipe( + Layer.provideMerge(MockAcpProviderLive), + Layer.provideMerge( + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: (ticketId) => + Effect.succeed({ + worktreeRef: `wt-${ticketId}`, + path: `/tmp/wt-${ticketId}`, + }), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const advanceRuntime = Effect.gen(function* () { + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; +}); + +const waitFor = (predicate: Effect.Effect, label: string): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (yield* predicate) { + return; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const waitForDetail = ( + read: WorkflowReadModel["Service"], + ticketId: string, + predicate: (detail: TicketDetail | null) => boolean, + label: string, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 20; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +runtimeLayer("WorkflowRuntimeCoreLive", (it) => { + it.effect("runs two real agent steps through the durable runtime", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + + yield* registry.register("board-runtime" as never, definition); + const ticketId = yield* engine.createTicket({ + boardId: "board-runtime" as never, + title: "Ship runtime", + initialLane: "code" as never, + }); + + yield* waitFor(mock.startedCount.pipe(Effect.map((count) => count === 1)), "first turn"); + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "review", + "review lane", + ); + + yield* waitFor(mock.startedCount.pipe(Effect.map((count) => count === 2)), "second turn"); + yield* mock.completeAllRunning(); + const done = yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "done lane", + ); + + assert.equal(done?.steps.filter((step) => step.status === "completed").length, 2); + }), + ); + + it.effect("recovers an in-flight dispatch without starting a duplicate provider turn", () => + Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const mock = yield* MockAcpProvider; + const provider = yield* ProviderTurnPort; + const sql = yield* SqlClient.SqlClient; + const baselineStarts = yield* mock.startedCount; + + yield* provider.ensureTurnStarted({ + dispatchId: "dispatch-restart" as never, + ticketId: "ticket-restart" as never, + stepRunId: "step-run-restart" as never, + threadId: "thread-restart" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "recover the turn", + worktreePath: "/tmp/wt-restart", + }); + assert.equal(yield* mock.startedCount, baselineStarts + 1); + + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-restart', + 'ticket-restart', + 'step-run-restart', + 'thread-restart', + 'codex', + 'gpt-5.5', + 'recover the turn', + '/tmp/wt-restart', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + const fiber = yield* Effect.forkChild(recovery.recover()); + yield* Effect.yieldNow; + yield* mock.completeAllRunning(); + yield* advanceRuntime; + yield* Fiber.join(fiber); + + yield* recovery.recover(); + + assert.equal(yield* mock.startedCount, baselineStarts + 1); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = 'dispatch-restart' + `; + assert.equal(rows[0]?.status, "confirmed"); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts new file mode 100644 index 00000000000..7612330ff08 --- /dev/null +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -0,0 +1,44 @@ +import * as Layer from "effect/Layer"; + +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { DurableApprovalResumeLive } from "./Layers/DurableApprovalResume.ts"; +import { + ProviderDispatchOutboxLive, + ProviderTurnPortLive, +} from "./Layers/ProviderDispatchOutbox.ts"; +import { ProviderResponsePortLive } from "./Layers/ProviderResponsePort.ts"; +import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecutor.ts"; +import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; +import { TurnProjectionPortLive, TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowRecoveryLive } from "./Layers/WorkflowRecovery.ts"; +import { WorktreeLeaseServiceLive } from "./Layers/WorktreeLeaseService.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +export const WorkflowRuntimeCoreLive = Layer.mergeAll( + WorkflowEngineLayer, + WorkflowRecoveryLive, +).pipe( + Layer.provideMerge(RealStepExecutorLive), + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge(TurnStateReaderLive), + Layer.provideMerge(SetupRunServiceLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(WorkflowFoundationLive), +); + +export const WorkflowRuntimeLive = WorkflowRuntimeCoreLive.pipe( + Layer.provideMerge(WorkflowIdsLive), + Layer.provideMerge(ProviderTurnPortLive), + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(SetupTerminalPortLive), + Layer.provideMerge(WorktreePortLive), + Layer.provideMerge(ProviderResponsePortLive), +); From a9eea3160b243c0d9b703e3efd4c77e245147150 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:11:10 -0400 Subject: [PATCH 038/295] feat(workflow): add ticket ref-name helpers Constraint: Ticket refs mirror checkpoint ref base64url encoding for stable git-safe names. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/ticketRefs.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M4 suite runs at milestone DoD. --- apps/server/src/workflow/ticketRefs.test.ts | 16 ++++++++++++++++ apps/server/src/workflow/ticketRefs.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 apps/server/src/workflow/ticketRefs.test.ts create mode 100644 apps/server/src/workflow/ticketRefs.ts diff --git a/apps/server/src/workflow/ticketRefs.test.ts b/apps/server/src/workflow/ticketRefs.test.ts new file mode 100644 index 00000000000..5df7c4ae6e8 --- /dev/null +++ b/apps/server/src/workflow/ticketRefs.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { ticketBaseRef, ticketStepRef } from "./ticketRefs.ts"; + +describe("ticketRefs", () => { + it("builds a stable base ref", () => { + assert.equal(ticketBaseRef("t-1" as never), "refs/t3/tickets/dC0x/base"); + }); + + it("builds pre/post step refs", () => { + assert.equal( + ticketStepRef("t-1" as never, "sr-1" as never, "pre"), + "refs/t3/tickets/dC0x/step/c3ItMQ/pre", + ); + }); +}); diff --git a/apps/server/src/workflow/ticketRefs.ts b/apps/server/src/workflow/ticketRefs.ts new file mode 100644 index 00000000000..9ce7a0db55b --- /dev/null +++ b/apps/server/src/workflow/ticketRefs.ts @@ -0,0 +1,18 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; + +export const TICKET_REFS_PREFIX = "refs/t3/tickets"; + +const encodeRefPart = (value: string) => Encoding.encodeBase64Url(value); + +export const ticketBaseRef = (ticketId: TicketId): string => + `${TICKET_REFS_PREFIX}/${encodeRefPart(ticketId as string)}/base`; + +export const ticketStepRef = ( + ticketId: TicketId, + stepRunId: StepRunId, + kind: "pre" | "post", +): string => + `${TICKET_REFS_PREFIX}/${encodeRefPart(ticketId as string)}/step/${encodeRefPart( + stepRunId as string, + )}/${kind}`; From b857974571467e625cb349a79087c94495c66eee Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:12:24 -0400 Subject: [PATCH 039/295] feat(workflow): add per-step checkpoint ref columns + StepRefsCaptured event Constraint: Step checkpoint refs are projected from workflow events so the read model stays event-derived. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/TicketCheckpointService.migration.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M4 suite runs at milestone DoD. --- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/038_WorkflowStepRefs.ts | 8 +++++++ .../TicketCheckpointService.migration.test.ts | 21 +++++++++++++++++++ .../Layers/WorkflowProjectionPipeline.ts | 9 ++++++++ packages/contracts/src/workflow.ts | 9 ++++++++ 5 files changed, 49 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts create mode 100644 apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index abd0723a1ee..de35cfae4d2 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_WorkflowTicketToken.ts"; import Migration0035 from "./Migrations/035_WorkflowLease.ts"; import Migration0036 from "./Migrations/036_WorkflowDispatchOutbox.ts"; import Migration0037 from "./Migrations/037_WorkflowSetupRun.ts"; +import Migration0038 from "./Migrations/038_WorkflowStepRefs.ts"; /** * Migration loader with all migrations defined inline. @@ -99,6 +100,7 @@ export const migrationEntries = [ [35, "WorkflowLease", Migration0035], [36, "WorkflowDispatchOutbox", Migration0036], [37, "WorkflowSetupRun", Migration0037], + [38, "WorkflowStepRefs", Migration0038], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts b/apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts new file mode 100644 index 00000000000..f33570c996c --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts @@ -0,0 +1,8 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN pre_checkpoint_ref TEXT`; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN post_checkpoint_ref TEXT`; +}); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts new file mode 100644 index 00000000000..0bd23e3feca --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("step refs migration", (it) => { + it.effect("projection_step_run has pre/post ref columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; + const names = cols.map((column) => column.name); + assert.isTrue(names.includes("pre_checkpoint_ref")); + assert.isTrue(names.includes("post_checkpoint_ref")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts index ccd76c9b62f..7b3be995975 100644 --- a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -164,6 +164,15 @@ const make = Effect.gen(function* () { `; break; } + case "StepRefsCaptured": { + yield* sql` + UPDATE projection_step_run + SET pre_checkpoint_ref = ${event.payload.preRef}, + post_checkpoint_ref = ${event.payload.postRef} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } case "StepCompleted": { yield* sql` UPDATE projection_step_run diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 9775745ce4c..4c3f86c5cbe 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -202,6 +202,15 @@ export const WorkflowEvent = Schema.Union([ type: Schema.Literal("StepUserResolved"), payload: Schema.Struct({ stepRunId: StepRunId }), }), + Schema.Struct({ + ...EventBase, + type: Schema.Literal("StepRefsCaptured"), + payload: Schema.Struct({ + stepRunId: StepRunId, + preRef: Schema.String, + postRef: Schema.String, + }), + }), Schema.Struct({ ...EventBase, type: Schema.Literal("StepCompleted"), From 6f67a37470c4c187d948adcd9e00dd6c07332cad Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:13:45 -0400 Subject: [PATCH 040/295] feat(workflow): add TicketCheckpointService (baseline + per-step refs) Constraint: Ticket checkpoint capture reuses CheckpointStore so ticket refs use the same git plumbing as thread checkpoints. Confidence: high Scope-risk: narrow Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/TicketCheckpointService.test.ts Tested: pnpm --filter t3 typecheck Not-tested: Full M4 suite runs at milestone DoD. --- .../Layers/TicketCheckpointService.test.ts | 99 +++++++++++++++++++ .../Layers/TicketCheckpointService.ts | 61 ++++++++++++ .../Services/TicketCheckpointService.ts | 27 +++++ 3 files changed, 187 insertions(+) create mode 100644 apps/server/src/workflow/Layers/TicketCheckpointService.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketCheckpointService.ts create mode 100644 apps/server/src/workflow/Services/TicketCheckpointService.ts diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts new file mode 100644 index 00000000000..36938c56157 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts @@ -0,0 +1,99 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import type { VcsError } from "@t3tools/contracts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-checkpoint-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); + +const layer = it.layer( + TicketCheckpointServiceLive.pipe( + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const makeTmpDir = ( + prefix = "ticket-checkpoint-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "TicketCheckpointService.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +layer("TicketCheckpointService", (it) => { + it.effect("captures a baseline ref that exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const service = yield* TicketCheckpointService; + + const ref = yield* service.captureBaseline("t-1" as never, tmp); + const exists = yield* service.hasBaseline("t-1" as never, tmp); + + assert.equal(ref, "refs/t3/tickets/dC0x/base"); + assert.equal(exists, true); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.ts new file mode 100644 index 00000000000..7b5639391a8 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.ts @@ -0,0 +1,61 @@ +import { CheckpointRef } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + TicketCheckpointService, + type TicketCheckpointServiceShape, +} from "../Services/TicketCheckpointService.ts"; +import { ticketBaseRef, ticketStepRef } from "../ticketRefs.ts"; + +const toCheckpointError = (cause: unknown) => + new WorkflowEventStoreError({ message: "checkpoint op failed", cause }); + +const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toCheckpointError)); + +const make = Effect.gen(function* () { + const checkpoints = yield* CheckpointStore; + + const captureBaseline: TicketCheckpointServiceShape["captureBaseline"] = (ticketId, cwd) => + Effect.gen(function* () { + const ref = ticketBaseRef(ticketId); + yield* wrap( + checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make(ref), + }), + ); + return ref; + }); + + const hasBaseline: TicketCheckpointServiceShape["hasBaseline"] = (ticketId, cwd) => + wrap( + checkpoints.hasCheckpointRef({ + cwd, + checkpointRef: CheckpointRef.make(ticketBaseRef(ticketId)), + }), + ); + + const captureStep: TicketCheckpointServiceShape["captureStep"] = ( + ticketId, + stepRunId, + cwd, + kind, + ) => + Effect.gen(function* () { + const ref = ticketStepRef(ticketId, stepRunId, kind); + yield* wrap( + checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make(ref), + }), + ); + return ref; + }); + + return { captureBaseline, hasBaseline, captureStep } satisfies TicketCheckpointServiceShape; +}); + +export const TicketCheckpointServiceLive = Layer.effect(TicketCheckpointService, make); diff --git a/apps/server/src/workflow/Services/TicketCheckpointService.ts b/apps/server/src/workflow/Services/TicketCheckpointService.ts new file mode 100644 index 00000000000..9c2d2948572 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketCheckpointService.ts @@ -0,0 +1,27 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface TicketCheckpointServiceShape { + readonly captureBaseline: ( + ticketId: TicketId, + cwd: string, + ) => Effect.Effect; + readonly hasBaseline: ( + ticketId: TicketId, + cwd: string, + ) => Effect.Effect; + readonly captureStep: ( + ticketId: TicketId, + stepRunId: StepRunId, + cwd: string, + kind: "pre" | "post", + ) => Effect.Effect; +} + +export class TicketCheckpointService extends Context.Service< + TicketCheckpointService, + TicketCheckpointServiceShape +>()("t3/workflow/Services/TicketCheckpointService") {} From 3a6dd9c8d50046ebc9a47ee4d7ba75dd0c16ba88 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:18:00 -0400 Subject: [PATCH 041/295] feat(workflow): add TicketDiffQuery (accumulated base->worktree diff) Constraint: Strict TDD task from Workflow Boards v1 M4; use public GitVcsDriver.execute for live diffs.\nRejected: Importing GitVcsDriverCore untracked helpers | internal helper is outside the public VCS service boundary.\nConfidence: high\nScope-risk: narrow\nDirective: Keep ticket diff plumbing behind WorktreeDiffPort so query logic stays stubbable.\nTested: pnpm --dir apps/server exec vp test run src/workflow/Layers/TicketDiffQuery.test.ts; pnpm --filter t3 typecheck\nNot-tested: Full milestone vp check deferred until M4 DoD. --- .../workflow/Layers/TicketDiffQuery.test.ts | 125 ++++++++++++++++++ .../src/workflow/Layers/TicketDiffQuery.ts | 99 ++++++++++++++ .../src/workflow/Services/TicketDiffQuery.ts | 31 +++++ packages/contracts/src/workflow.ts | 16 +++ 4 files changed, 271 insertions(+) create mode 100644 apps/server/src/workflow/Layers/TicketDiffQuery.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketDiffQuery.ts create mode 100644 apps/server/src/workflow/Services/TicketDiffQuery.ts diff --git a/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts b/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts new file mode 100644 index 00000000000..c3e011a0048 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { VcsError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-diff-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +const layer = it.layer( + TicketDiffQueryLive.pipe( + Layer.provideMerge(WorktreeDiffPortLive), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const makeTmpDir = ( + prefix = "ticket-diff-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "TicketDiffQuery.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +layer("TicketDiffQuery", (it) => { + it.effect("returns accumulated base-to-worktree diff for tracked and untracked files", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointService = yield* TicketCheckpointService; + const query = yield* TicketDiffQuery; + const ticketId = "t-1" as never; + + const baseRef = yield* checkpointService.captureBaseline(ticketId, tmp); + yield* writeTextFile(path.join(tmp, "README.md"), "# changed\n"); + yield* writeTextFile(path.join(tmp, "notes.txt"), "new note\n"); + + const diff = yield* query.getTicketDiff(ticketId, tmp, baseRef); + + assert.equal(diff.ticketId, ticketId); + assert.equal(diff.baseRef, baseRef); + assert.equal(diff.truncated, false); + assert.include(diff.patch, "diff --git"); + assert.include(diff.patch, "README.md"); + assert.include(diff.patch, "notes.txt"); + assert.deepEqual( + new Map(diff.files.map((file) => [file.path, file])), + new Map([ + ["README.md", { path: "README.md", additions: 1, deletions: 1 }], + ["notes.txt", { path: "notes.txt", additions: 1, deletions: 0 }], + ]), + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketDiffQuery.ts b/apps/server/src/workflow/Layers/TicketDiffQuery.ts new file mode 100644 index 00000000000..867a549dd84 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketDiffQuery.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + TicketDiffQuery, + WorktreeDiffPort, + type TicketDiffQueryShape, + type WorktreeDiffPortShape, +} from "../Services/TicketDiffQuery.ts"; + +const make = Effect.gen(function* () { + const port = yield* WorktreeDiffPort; + + const getTicketDiff: TicketDiffQueryShape["getTicketDiff"] = (ticketId, cwd, baseRef) => + Effect.gen(function* () { + const { patch, truncated } = yield* port.diffRefToWorktree({ cwd, baseRef }); + const files = parseTurnDiffFilesFromUnifiedDiff(patch); + + return { + ticketId, + baseRef, + patch, + files, + truncated, + }; + }); + + return { getTicketDiff } satisfies TicketDiffQueryShape; +}); + +export const TicketDiffQueryLive = Layer.effect(TicketDiffQuery, make); + +export const WorktreeDiffPortLive = Layer.effect( + WorktreeDiffPort, + Effect.gen(function* () { + const git = yield* GitVcsDriver; + + const diffRefToWorktree: WorktreeDiffPortShape["diffRefToWorktree"] = ({ cwd, baseRef }) => + Effect.gen(function* () { + const tracked = yield* git.execute({ + operation: "WorkflowTicketDiff.tracked", + cwd, + args: ["diff", "--patch", "--minimal", `${baseRef}^{commit}`, "--"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }); + const untrackedList = yield* git + .execute({ + operation: "WorkflowTicketDiff.untracked.list", + cwd, + args: ["ls-files", "--others", "--exclude-standard", "-z"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.orElseSucceed(() => ({ + stdout: "", + stdoutTruncated: false, + })), + ); + const untrackedPaths = untrackedList.stdout.split("\0").filter((path) => path.length > 0); + const untrackedDiffs = yield* Effect.forEach( + untrackedPaths, + (path) => + git.execute({ + operation: "WorkflowTicketDiff.untracked.diff", + cwd, + args: ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", path], + allowNonZeroExit: true, + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }), + { concurrency: 4 }, + ); + + return { + patch: [ + tracked.stdout.trimEnd(), + ...untrackedDiffs.map((result) => result.stdout.trimEnd()), + ] + .filter((part) => part.length > 0) + .join("\n"), + truncated: + tracked.stdoutTruncated || + untrackedList.stdoutTruncated || + untrackedDiffs.some((result) => result.stdoutTruncated), + }; + }).pipe( + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "ticket diff failed", cause }), + ), + ); + + return { diffRefToWorktree } satisfies WorktreeDiffPortShape; + }), +); diff --git a/apps/server/src/workflow/Services/TicketDiffQuery.ts b/apps/server/src/workflow/Services/TicketDiffQuery.ts new file mode 100644 index 00000000000..854a7810618 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketDiffQuery.ts @@ -0,0 +1,31 @@ +import type { TicketDiff, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeDiffPortShape { + readonly diffRefToWorktree: (input: { + readonly cwd: string; + readonly baseRef: string; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + WorkflowEventStoreError + >; +} + +export class WorktreeDiffPort extends Context.Service()( + "t3/workflow/Services/TicketDiffQuery/WorktreeDiffPort", +) {} + +export interface TicketDiffQueryShape { + readonly getTicketDiff: ( + ticketId: TicketId, + cwd: string, + baseRef: string, + ) => Effect.Effect; +} + +export class TicketDiffQuery extends Context.Service()( + "t3/workflow/Services/TicketDiffQuery", +) {} diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 4c3f86c5cbe..efd85f55e85 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -235,3 +235,19 @@ export const StepOutcome = Schema.Union([ Schema.Struct({ _tag: Schema.Literal("awaiting_user"), waitingReason: Schema.String }), ]); export type StepOutcome = typeof StepOutcome.Type; + +export const TicketDiffFile = Schema.Struct({ + path: Schema.String, + additions: Schema.Int, + deletions: Schema.Int, +}); +export type TicketDiffFile = typeof TicketDiffFile.Type; + +export const TicketDiff = Schema.Struct({ + ticketId: TicketId, + baseRef: Schema.String, + patch: Schema.String, + files: Schema.Array(TicketDiffFile), + truncated: Schema.Boolean, +}); +export type TicketDiff = typeof TicketDiff.Type; From 034cbd92ea5989fe85c222e6b2026f4761ad198b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:22:21 -0400 Subject: [PATCH 042/295] feat(workflow): capture ticket baseline + per-step refs during execution Constraint: Strict TDD task from Workflow Boards v1 M4; first-step baseline capture belongs after worktree creation, not ticket creation.\nRejected: Capturing refs inside ticket creation | M3 creates worktrees lazily on first agent step.\nConfidence: high\nScope-risk: moderate\nDirective: Keep TicketCheckpointService injectable in WorkflowRuntimeCoreLive tests and provide the live bridge only in production runtime.\nTested: pnpm --dir apps/server exec vp test run src/workflow/Layers/RealStepExecutor.test.ts; pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowRuntime.integration.test.ts; pnpm --filter t3 typecheck\nNot-tested: Full milestone vp check deferred until M4 DoD. --- .../workflow/Layers/RealStepExecutor.test.ts | 87 +++++++++++++++++++ .../src/workflow/Layers/RealStepExecutor.ts | 58 ++++++++++--- .../WorkflowRuntime.integration.test.ts | 9 ++ .../src/workflow/WorkflowRuntimeLive.ts | 2 + 4 files changed, 146 insertions(+), 10 deletions(-) diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts index adad933617d..cd4bb3aea2f 100644 --- a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -9,7 +9,11 @@ import { MigrationsLive } from "../../persistence/Migrations.ts"; import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; import { SetupRunService } from "../Services/SetupRunService.ts"; import { StepExecutor } from "../Services/StepExecutor.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import { WorktreePort } from "../Services/WorktreePort.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; import { RealStepExecutorLive } from "./RealStepExecutor.ts"; @@ -31,6 +35,10 @@ const context: StepExecutionContext = { }, }; +const checkpointCalls: Array = []; +const preRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/pre"; +const postRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/post"; + const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => it.layer( RealStepExecutorLive.pipe( @@ -46,6 +54,25 @@ const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), }), ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: (_ticketId, cwd) => + Effect.sync(() => { + checkpointCalls.push(`hasBaseline:${cwd}`); + return false; + }), + captureBaseline: (_ticketId, cwd) => + Effect.sync(() => { + checkpointCalls.push(`captureBaseline:${cwd}`); + return "refs/t3/tickets/dC0x/base"; + }), + captureStep: (_ticketId, stepRunId, cwd, kind) => + Effect.sync(() => { + checkpointCalls.push(`captureStep:${stepRunId}:${cwd}:${kind}`); + return kind === "pre" ? preRef : postRef; + }), + }), + ), Layer.provideMerge( Layer.succeed(ProviderDispatchOutbox, { ensureStarted: () => Effect.void, @@ -53,17 +80,61 @@ const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => recoverPending: () => Effect.void, }), ), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowFoundationLive), Layer.provideMerge(DeterministicWorkflowIds), Layer.provideMerge(MigrationsLive), Layer.provideMerge(SqlitePersistenceMemory), ), ); +const seedStepStarted = Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + yield* committer.commit({ + type: "StepStarted", + eventId: "event-step-started" as never, + ticketId: context.ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + pipelineRunId: context.pipelineRunId, + stepRunId: context.stepRunId, + stepKey: context.step.key, + stepType: "agent", + }, + }); +}); + +const assertProjectedStepRefs = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const events = yield* sql<{ readonly type: string }>` + SELECT event_type AS "type" + FROM workflow_events + WHERE ticket_id = ${context.ticketId} + AND event_type = 'StepRefsCaptured' + `; + const rows = yield* sql<{ + readonly preCheckpointRef: string | null; + readonly postCheckpointRef: string | null; + }>` + SELECT + pre_checkpoint_ref AS "preCheckpointRef", + post_checkpoint_ref AS "postCheckpointRef" + FROM projection_step_run + WHERE step_run_id = ${context.stepRunId} + `; + + assert.equal(events.length, 1); + assert.equal(rows[0]?.preCheckpointRef, preRef); + assert.equal(rows[0]?.postCheckpointRef, postRef); +}); + mk({ ok: true })("RealStepExecutor success", (it) => { it.effect("completes an agent step and releases the worktree lease", () => Effect.gen(function* () { + checkpointCalls.length = 0; const executor = yield* StepExecutor; const sql = yield* SqlClient.SqlClient; + yield* seedStepStarted; const outcome = yield* executor.execute(context); @@ -74,6 +145,13 @@ mk({ ok: true })("RealStepExecutor success", (it) => { WHERE worktree_ref = 'wt-ticket-1' `; assert.equal(rows[0]?.ownerKind, "released"); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-1:/tmp/wt-ticket-1:pre", + "captureStep:step-run-1:/tmp/wt-ticket-1:post", + ]); + yield* assertProjectedStepRefs; }), ); }); @@ -81,11 +159,20 @@ mk({ ok: true })("RealStepExecutor success", (it) => { mk({ ok: false, error: "provider failed" })("RealStepExecutor failure", (it) => { it.effect("fails an agent step when provider dispatch fails", () => Effect.gen(function* () { + checkpointCalls.length = 0; const executor = yield* StepExecutor; + yield* seedStepStarted; const outcome = yield* executor.execute(context); assert.deepEqual(outcome, { _tag: "failed", error: "provider failed" }); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-1:/tmp/wt-ticket-1:pre", + "captureStep:step-run-1:/tmp/wt-ticket-1:post", + ]); + yield* assertProjectedStepRefs; }), ); }); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts index 6673cdc78b8..db3464da9c3 100644 --- a/apps/server/src/workflow/Layers/RealStepExecutor.ts +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -1,12 +1,16 @@ import { TrimmedNonEmptyString, type StepOutcome } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; import { SetupRunService } from "../Services/SetupRunService.ts"; import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import { WorkflowIds } from "../Services/WorkflowIds.ts"; import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; import { @@ -24,6 +28,8 @@ const make = Effect.gen(function* () { const setup = yield* SetupRunService; const dispatch = yield* ProviderDispatchOutbox; const ids = yield* WorkflowIds; + const ticketCheckpoints = yield* TicketCheckpointService; + const committer = yield* WorkflowEventCommitter; const execute: StepExecutorShape["execute"] = (ctx) => Effect.gen(function* () { @@ -33,6 +39,11 @@ const make = Effect.gen(function* () { } const worktree = yield* worktrees.ensureWorktree(ctx.ticketId); + const hasBaseline = yield* ticketCheckpoints.hasBaseline(ctx.ticketId, worktree.path); + if (!hasBaseline) { + yield* ticketCheckpoints.captureBaseline(ctx.ticketId, worktree.path); + } + const setupRunId = yield* ids.eventId(); const setupResult = yield* setup.runSetup( ctx.ticketId, @@ -58,18 +69,45 @@ const make = Effect.gen(function* () { ); const result = yield* Effect.gen(function* () { - yield* dispatch.ensureStarted({ - dispatchId: dispatchId as never, + const preRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + worktree.path, + "pre", + ); + const terminalExit = yield* Effect.gen(function* () { + yield* dispatch.ensureStarted({ + dispatchId: dispatchId as never, + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + threadId: threadId as never, + providerInstance: step.agent.instance as string, + model: step.agent.model as string, + instruction, + worktreePath: worktree.path, + }); + return yield* dispatch.awaitTerminal(dispatchId as never, threadId as never); + }).pipe(Effect.exit, Effect.ensuring(releaseIfStillOwner)); + const postRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + worktree.path, + "post", + ); + const eventId = yield* ids.eventId(); + const occurredAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* committer.commit({ + type: "StepRefsCaptured", + eventId: eventId as never, ticketId: ctx.ticketId, - stepRunId: ctx.stepRunId, - threadId: threadId as never, - providerInstance: step.agent.instance as string, - model: step.agent.model as string, - instruction, - worktreePath: worktree.path, + occurredAt: occurredAt as never, + payload: { stepRunId: ctx.stepRunId, preRef, postRef }, }); - return yield* dispatch.awaitTerminal(dispatchId as never, threadId as never); - }).pipe(Effect.ensuring(releaseIfStillOwner)); + if (Exit.isFailure(terminalExit)) { + return yield* Effect.failCause(terminalExit.cause); + } + return terminalExit.value; + }); return result.ok ? ({ _tag: "completed" } satisfies StepOutcome) diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts index f8409f88a06..0e93a2500f3 100644 --- a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts @@ -9,6 +9,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; import { WorktreePort } from "../Services/WorktreePort.ts"; import { BoardRegistry } from "../Services/BoardRegistry.ts"; import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; @@ -71,6 +72,14 @@ const runtimeLayer = it.layer( }), }), ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(false), + captureBaseline: (ticketId) => Effect.succeed(`refs/t3/tickets/${ticketId}/base` as string), + captureStep: (ticketId, stepRunId, _cwd, kind) => + Effect.succeed(`refs/t3/tickets/${ticketId}/steps/${stepRunId}/${kind}` as string), + }), + ), Layer.provideMerge(DeterministicWorkflowIds), Layer.provideMerge(MigrationsLive), Layer.provideMerge(SqlitePersistenceMemory), diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts index 7612330ff08..485a8ad457e 100644 --- a/apps/server/src/workflow/WorkflowRuntimeLive.ts +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -10,6 +10,7 @@ import { import { ProviderResponsePortLive } from "./Layers/ProviderResponsePort.ts"; import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecutor.ts"; import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; +import { TicketCheckpointServiceLive } from "./Layers/TicketCheckpointService.ts"; import { TurnProjectionPortLive, TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; @@ -39,6 +40,7 @@ export const WorkflowRuntimeLive = WorkflowRuntimeCoreLive.pipe( Layer.provideMerge(ProviderTurnPortLive), Layer.provideMerge(TurnProjectionPortLive), Layer.provideMerge(SetupTerminalPortLive), + Layer.provideMerge(TicketCheckpointServiceLive), Layer.provideMerge(WorktreePortLive), Layer.provideMerge(ProviderResponsePortLive), ); From 3f18dda34314316ece5e0d4bc8a37a4315c36d28 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:25:22 -0400 Subject: [PATCH 043/295] feat(workflow): add workflow.* RPC method + board schemas + scopes Constraint: Strict TDD task from Workflow Boards v1 M5; expose workflow RPCs through the shared contracts first.\nRejected: Deferring auth scopes to server wiring | RPC authorization needs contract-level scope names before handlers.\nConfidence: high\nScope-risk: narrow\nDirective: Keep workflow RPC payloads schema-only in contracts; server mapping belongs in workflow RPC handlers.\nTested: pnpm --dir packages/contracts exec vp test run src/workflowRpc.test.ts; pnpm --filter @t3tools/contracts typecheck\nNot-tested: Server/client RPC wiring, covered by later M5 tasks. --- packages/contracts/src/auth.ts | 6 ++ packages/contracts/src/rpc.ts | 90 ++++++++++++++++++++++ packages/contracts/src/workflow.ts | 67 ++++++++++++++++ packages/contracts/src/workflowRpc.test.ts | 74 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 packages/contracts/src/workflowRpc.test.ts diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 70b2899757d..8902700e145 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -75,6 +75,8 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; export const AuthOrchestrationReadScope = "orchestration:read" as const; export const AuthOrchestrationOperateScope = "orchestration:operate" as const; +export const AuthWorkflowReadScope = "workflow:read" as const; +export const AuthWorkflowOperateScope = "workflow:operate" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthReviewWriteScope = "review:write" as const; export const AuthAccessReadScope = "access:read" as const; @@ -84,6 +86,8 @@ export const AuthRelayWriteScope = "relay:write" as const; export const AuthEnvironmentScope = Schema.Literals([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, @@ -98,6 +102,8 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthRelayReadScope, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..9fbd03a64c3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,6 +8,7 @@ import { AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; +import { ProjectId } from "./baseSchemas.ts"; import { FilesystemBrowseInput, FilesystemBrowseResult, @@ -115,6 +116,18 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + BoardId, + BoardSnapshot, + BoardStreamItem, + LaneKey, + StepRunId, + TicketDiff, + TicketId, + WorkflowRpcError, + WorkflowTicketDetailView, + WORKFLOW_WS_METHODS, +} from "./workflow.ts"; export const WS_METHODS = { // Project registry methods @@ -510,6 +523,74 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( }, ); +export const WsWorkflowRegisterBoardFromFileRpc = Rpc.make( + WORKFLOW_WS_METHODS.registerBoardFromFile, + { + payload: Schema.Struct({ + boardId: BoardId, + projectId: ProjectId, + filePath: Schema.String, + repoRoot: Schema.String, + }), + success: Schema.Struct({ boardId: BoardId }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + }, +); + +export const WsWorkflowGetBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoard, { + payload: Schema.Struct({ boardId: BoardId }), + success: BoardSnapshot, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowSubscribeBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.subscribeBoard, { + payload: Schema.Struct({ boardId: BoardId }), + success: BoardStreamItem, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), + stream: true, +}); + +export const WsWorkflowCreateTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.createTicket, { + payload: Schema.Struct({ + boardId: BoardId, + title: Schema.String, + description: Schema.optional(Schema.String), + initialLane: LaneKey, + }), + success: Schema.Struct({ ticketId: TicketId }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowMoveTicketRpc = Rpc.make(WORKFLOW_WS_METHODS.moveTicket, { + payload: Schema.Struct({ ticketId: TicketId, toLane: LaneKey }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowRunLaneRpc = Rpc.make(WORKFLOW_WS_METHODS.runLane, { + payload: Schema.Struct({ ticketId: TicketId }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowResolveApprovalRpc = Rpc.make(WORKFLOW_WS_METHODS.resolveApproval, { + payload: Schema.Struct({ stepRunId: StepRunId, approved: Schema.Boolean }), + success: Schema.Void, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetTicketDetailRpc = Rpc.make(WORKFLOW_WS_METHODS.getTicketDetail, { + payload: Schema.Struct({ ticketId: TicketId }), + success: WorkflowTicketDetailView, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowGetTicketDiffRpc = Rpc.make(WORKFLOW_WS_METHODS.getTicketDiff, { + payload: Schema.Struct({ ticketId: TicketId }), + success: TicketDiff, + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, @@ -599,4 +680,13 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, + WsWorkflowRegisterBoardFromFileRpc, + WsWorkflowGetBoardRpc, + WsWorkflowSubscribeBoardRpc, + WsWorkflowCreateTicketRpc, + WsWorkflowMoveTicketRpc, + WsWorkflowRunLaneRpc, + WsWorkflowResolveApprovalRpc, + WsWorkflowGetTicketDetailRpc, + WsWorkflowGetTicketDiffRpc, ); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index efd85f55e85..62c53490224 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -2,6 +2,18 @@ import * as Schema from "effect/Schema"; import { ApprovalRequestId, IsoDateTime, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +export const WORKFLOW_WS_METHODS = { + registerBoardFromFile: "workflow.registerBoardFromFile", + getBoard: "workflow.getBoard", + subscribeBoard: "workflow.subscribeBoard", + createTicket: "workflow.createTicket", + moveTicket: "workflow.moveTicket", + runLane: "workflow.runLane", + resolveApproval: "workflow.resolveApproval", + getTicketDetail: "workflow.getTicketDetail", + getTicketDiff: "workflow.getTicketDiff", +} as const; + const makeId = (brand: Brand) => TrimmedNonEmptyString.pipe(Schema.brand(brand)); @@ -251,3 +263,58 @@ export const TicketDiff = Schema.Struct({ truncated: Schema.Boolean, }); export type TicketDiff = typeof TicketDiff.Type; + +export const BoardTicketView = Schema.Struct({ + ticketId: TicketId, + boardId: BoardId, + title: Schema.String, + currentLaneKey: LaneKey, + status: TicketStatus, +}); +export type BoardTicketView = typeof BoardTicketView.Type; + +export const BoardSnapshot = Schema.Struct({ + board: Schema.Struct({ + boardId: BoardId, + name: Schema.String, + lanes: Schema.Array( + Schema.Struct({ + key: LaneKey, + name: Schema.String, + entry: LaneEntry, + terminal: Schema.optional(Schema.Boolean), + }), + ), + }), + tickets: Schema.Array(BoardTicketView), +}); +export type BoardSnapshot = typeof BoardSnapshot.Type; + +export const BoardStreamItem = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: BoardSnapshot }), + Schema.Struct({ kind: Schema.Literal("ticket"), ticket: BoardTicketView }), +]); +export type BoardStreamItem = typeof BoardStreamItem.Type; + +export const WorkflowStepRunView = Schema.Struct({ + stepRunId: StepRunId, + stepKey: StepKey, + stepType: Schema.Union([Schema.Literal("agent"), Schema.Literal("approval")]), + status: StepRunStatus, + waitingReason: Schema.NullOr(Schema.String), +}); +export type WorkflowStepRunView = typeof WorkflowStepRunView.Type; + +export const WorkflowTicketDetailView = Schema.Struct({ + ticket: BoardTicketView, + steps: Schema.Array(WorkflowStepRunView), +}); +export type WorkflowTicketDetailView = typeof WorkflowTicketDetailView.Type; + +export class WorkflowRpcError extends Schema.TaggedErrorClass()( + "WorkflowRpcError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/workflowRpc.test.ts b/packages/contracts/src/workflowRpc.test.ts new file mode 100644 index 00000000000..5ee7d8d96e6 --- /dev/null +++ b/packages/contracts/src/workflowRpc.test.ts @@ -0,0 +1,74 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + AuthEnvironmentScope, + AuthStandardClientScopes, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, +} from "./auth.ts"; +import { + BoardStreamItem, + WORKFLOW_WS_METHODS, + WorkflowRpcError, + WsWorkflowCreateTicketRpc, + WsWorkflowGetTicketDiffRpc, + WsWorkflowSubscribeBoardRpc, +} from "./index.ts"; + +const decodeAuthScope = Schema.decodeUnknownEffect(AuthEnvironmentScope); +const decodeBoardStreamItem = Schema.decodeUnknownEffect(BoardStreamItem); + +describe("workflow RPC contracts", () => { + it("declares workflow websocket method names", () => { + assert.equal(WORKFLOW_WS_METHODS.createTicket, "workflow.createTicket"); + assert.equal(WORKFLOW_WS_METHODS.subscribeBoard, "workflow.subscribeBoard"); + assert.equal(WORKFLOW_WS_METHODS.getTicketDiff, "workflow.getTicketDiff"); + }); + + it.effect("decodes board snapshots for subscription streams", () => + Effect.gen(function* () { + const item = yield* decodeBoardStreamItem({ + kind: "snapshot", + snapshot: { + board: { + boardId: "board-1", + name: "Delivery", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + }, + tickets: [ + { + ticketId: "ticket-1", + boardId: "board-1", + title: "Ship workflow UI", + currentLaneKey: "backlog", + status: "idle", + }, + ], + }, + }); + + assert.equal(item.kind, "snapshot"); + if (item.kind === "snapshot") { + assert.equal(item.snapshot.tickets[0]?.title, "Ship workflow UI"); + } + }), + ); + + it.effect("adds workflow scopes to the environment and standard client grants", () => + Effect.gen(function* () { + assert.equal(yield* decodeAuthScope(AuthWorkflowReadScope), AuthWorkflowReadScope); + assert.equal(yield* decodeAuthScope(AuthWorkflowOperateScope), AuthWorkflowOperateScope); + assert.isTrue(AuthStandardClientScopes.includes(AuthWorkflowReadScope)); + assert.isTrue(AuthStandardClientScopes.includes(AuthWorkflowOperateScope)); + }), + ); + + it("exports workflow RPC definitions and error type", () => { + assert.isDefined(WsWorkflowCreateTicketRpc); + assert.isDefined(WsWorkflowSubscribeBoardRpc); + assert.isDefined(WsWorkflowGetTicketDiffRpc); + assert.equal(new WorkflowRpcError({ message: "workflow failed" })._tag, "WorkflowRpcError"); + }); +}); From e26e6cc0f4a486582d6344df238cce83e4507a44 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:28:32 -0400 Subject: [PATCH 044/295] feat(workflow): add WorkflowFileLoader with real lint checks Constraint: Strict TDD task from Workflow Boards v1 M5; loader must lint provider instances and instruction files before board registration.\nRejected: Baking filesystem/provider checks into BoardRegistry | loader-specific ports keep registry behavior and tests narrow.\nConfidence: medium\nScope-risk: moderate\nDirective: Use WorkflowFilePort and WorkflowProviderInstancePort for tests; production ports should remain thin live bridges.\nTested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowFileLoader.test.ts\nNot-tested: Full server typecheck is blocked until M5 Task 4 adds workflow RPC handlers for the Task 1 contract methods. --- .../Layers/WorkflowFileLoader.test.ts | 110 ++++++++++++++ .../src/workflow/Layers/WorkflowFileLoader.ts | 135 ++++++++++++++++++ .../workflow/Services/WorkflowFileLoader.ts | 39 +++++ 3 files changed, 284 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowFileLoader.ts create mode 100644 apps/server/src/workflow/Services/WorkflowFileLoader.ts diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts new file mode 100644 index 00000000000..62a4e199850 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts @@ -0,0 +1,110 @@ +import { assert, it } from "@effect/vitest"; +import type { BoardId, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowFileLoader, + WorkflowFilePort, + WorkflowProviderInstancePort, +} from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowFileLoaderLive } from "./WorkflowFileLoader.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const workflowJson = (providerInstance = "codex_main") => + JSON.stringify({ + name: "Delivery Board", + settings: { maxConcurrentTickets: 2 }, + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: providerInstance, model: "gpt-5.5" }, + instruction: { file: "prompts/implement.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + +const mk = (providerInstanceExists: (instanceId: string) => boolean) => + it.layer( + WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(workflowJson()), + instructionFileExists: ({ repoRelativePath }) => + Effect.succeed(repoRelativePath === "prompts/implement.md"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => + Effect.succeed(providerInstanceExists(instanceId)), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +mk((instanceId) => instanceId === "codex_main")("WorkflowFileLoader", (it) => { + it.effect("loads, lints, registers, and persists a workflow board", () => + Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const boardId = "board-loader" as BoardId; + + const loadedBoardId = yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + filePath: ".t3/boards/delivery.json", + repoRoot: "/repo", + }); + + const definition = yield* registry.getDefinition(boardId); + const board = yield* read.getBoard(boardId); + + assert.equal(loadedBoardId, boardId); + assert.equal(definition?.name, "Delivery Board"); + assert.equal(board?.name, "Delivery Board"); + assert.equal(board?.workflowFilePath, ".t3/boards/delivery.json"); + assert.equal(board?.maxConcurrentTickets, 2); + assert.isTrue((board?.workflowVersionHash.length ?? 0) > 0); + }), + ); +}); + +mk(() => false)("WorkflowFileLoader lint failure", (it) => { + it.effect("fails when the workflow references an unknown provider instance", () => + Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + + const result = yield* Effect.exit( + loader.loadAndRegister({ + boardId: "board-loader-fail" as BoardId, + projectId: "project-loader" as ProjectId, + filePath: ".t3/boards/delivery.json", + repoRoot: "/repo", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts new file mode 100644 index 00000000000..70e30e91965 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts @@ -0,0 +1,135 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; +import { createHash } from "node:crypto"; + +import { ProviderInstanceId, WorkflowDefinition, WorkflowRpcError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowFileLoader, + WorkflowFilePort, + WorkflowProviderInstancePort, + type WorkflowFileLoaderShape, + type WorkflowFilePortShape, + type WorkflowProviderInstancePortShape, +} from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { lintWorkflowDefinition } from "../workflowFile.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); +const decodeProviderInstanceId = Schema.decodeUnknownEffect(ProviderInstanceId); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + new WorkflowRpcError({ message, cause }); + +const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex"); + +const unique = (values: ReadonlyArray) => Array.from(new Set(values)); + +const make = Effect.gen(function* () { + const files = yield* WorkflowFilePort; + const providers = yield* WorkflowProviderInstancePort; + const boardRegistry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + + const loadAndRegister: WorkflowFileLoaderShape["loadAndRegister"] = (input) => + Effect.gen(function* () { + const raw = yield* files.readFileString(input.filePath); + const definition = yield* decodeWorkflowDefinitionJson(raw).pipe( + Effect.mapError(toWorkflowRpcError("workflow file decode failed")), + ); + + const agentSteps = definition.lanes.flatMap((lane) => + (lane.pipeline ?? []).flatMap((step) => (step.type === "agent" ? [step] : [])), + ); + const providerEntries = yield* Effect.forEach( + unique(agentSteps.map((step) => step.agent.instance as string)), + (instanceId) => + providers + .providerInstanceExists(instanceId) + .pipe(Effect.map((exists) => [instanceId, exists] as const)), + { concurrency: "unbounded" }, + ); + const instructionEntries = yield* Effect.forEach( + unique( + agentSteps.flatMap((step) => + typeof step.instruction === "object" ? [step.instruction.file as string] : [], + ), + ), + (repoRelativePath) => + files + .instructionFileExists({ repoRoot: input.repoRoot, repoRelativePath }) + .pipe(Effect.map((exists) => [repoRelativePath, exists] as const)), + { concurrency: "unbounded" }, + ); + const providerExists = new Map(providerEntries); + const instructionExists = new Map(instructionEntries); + const lintErrors = lintWorkflowDefinition(definition, { + providerInstanceExists: (instanceId) => providerExists.get(instanceId) ?? false, + instructionFileExists: (repoRelativePath) => + instructionExists.get(repoRelativePath) ?? false, + }); + if (lintErrors.length > 0) { + return yield* new WorkflowRpcError({ + message: `Workflow lint failed: ${lintErrors.map((error) => error.code).join(", ")}`, + }); + } + + yield* boardRegistry + .register(input.boardId, definition) + .pipe(Effect.mapError(toWorkflowRpcError("workflow board registration failed"))); + yield* readModel + .registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.filePath, + workflowVersionHash: sha256Hex(raw), + maxConcurrentTickets: definition.settings?.maxConcurrentTickets ?? 3, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow board projection registration failed"))); + return input.boardId; + }); + + return { loadAndRegister } satisfies WorkflowFileLoaderShape; +}); + +export const WorkflowFileLoaderLive = Layer.effect(WorkflowFileLoader, make); + +export const WorkflowFilePortLive = Layer.effect( + WorkflowFilePort, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + readFileString: (filePath) => + fileSystem + .readFileString(filePath) + .pipe(Effect.mapError(toWorkflowRpcError("workflow file read failed"))), + instructionFileExists: ({ repoRoot, repoRelativePath }) => + fileSystem.exists(path.join(repoRoot, repoRelativePath)).pipe( + Effect.map((exists): boolean => exists), + Effect.orElseSucceed(() => false), + ), + } satisfies WorkflowFilePortShape; + }), +); + +export const WorkflowProviderInstancePortLive = Layer.effect( + WorkflowProviderInstancePort, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return { + providerInstanceExists: (instanceId) => + decodeProviderInstanceId(instanceId).pipe( + Effect.flatMap((decoded) => registry.getInstance(decoded)), + Effect.map((instance) => instance !== undefined), + Effect.orElseSucceed(() => false), + ), + } satisfies WorkflowProviderInstancePortShape; + }), +); diff --git a/apps/server/src/workflow/Services/WorkflowFileLoader.ts b/apps/server/src/workflow/Services/WorkflowFileLoader.ts new file mode 100644 index 00000000000..03d9d203730 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowFileLoader.ts @@ -0,0 +1,39 @@ +import type { BoardId, ProjectId } from "@t3tools/contracts"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowFilePortShape { + readonly readFileString: (filePath: string) => Effect.Effect; + readonly instructionFileExists: (input: { + readonly repoRoot: string; + readonly repoRelativePath: string; + }) => Effect.Effect; +} + +export class WorkflowFilePort extends Context.Service()( + "t3/workflow/Services/WorkflowFileLoader/WorkflowFilePort", +) {} + +export interface WorkflowProviderInstancePortShape { + readonly providerInstanceExists: (instanceId: string) => Effect.Effect; +} + +export class WorkflowProviderInstancePort extends Context.Service< + WorkflowProviderInstancePort, + WorkflowProviderInstancePortShape +>()("t3/workflow/Services/WorkflowFileLoader/WorkflowProviderInstancePort") {} + +export interface WorkflowFileLoaderShape { + readonly loadAndRegister: (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly filePath: string; + readonly repoRoot: string; + }) => Effect.Effect; +} + +export class WorkflowFileLoader extends Context.Service< + WorkflowFileLoader, + WorkflowFileLoaderShape +>()("t3/workflow/Services/WorkflowFileLoader") {} From 06892bbbf64b2ad78a292aab76924cf91807fa90 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:30:52 -0400 Subject: [PATCH 045/295] feat(workflow): publish board ticket deltas for subscriptions Constraint: Strict TDD task from Workflow Boards v1 M5; subscription deltas must publish after projection so clients see current ticket state.\nRejected: Publishing raw workflow events to board subscribers | board UI needs projected ticket views, not event payload branching.\nConfidence: high\nScope-risk: moderate\nDirective: Keep WorkflowBoardEvents as a projection-view PubSub; RPC subscribeBoard should layer snapshots on top.\nTested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowBoardEvents.test.ts; pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowEventCommitter.test.ts\nNot-tested: Full server typecheck is blocked until M5 Task 4 adds workflow RPC handlers for the Task 1 contract methods. --- .../Layers/WorkflowBoardEvents.test.ts | 54 +++++++++++++++++++ .../workflow/Layers/WorkflowBoardEvents.ts | 23 ++++++++ .../workflow/Layers/WorkflowEventCommitter.ts | 27 ++++++++++ .../workflow/Services/WorkflowBoardEvents.ts | 14 +++++ .../src/workflow/WorkflowRuntimeLive.ts | 2 + 5 files changed, 120 insertions(+) create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardEvents.ts create mode 100644 apps/server/src/workflow/Services/WorkflowBoardEvents.ts diff --git a/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts new file mode 100644 index 00000000000..8e3316e41c1 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts @@ -0,0 +1,54 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowBoardEventsLive } from "./WorkflowBoardEvents.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowBoardEvents", (it) => { + it.effect("publishes a ticket delta after the committer projects a ticket event", () => + Effect.gen(function* () { + const events = yield* WorkflowBoardEvents; + const committer = yield* WorkflowEventCommitter; + const deltasFiber = yield* events + .stream("b-1" as never) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped); + yield* Effect.yieldNow; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Board delta" as never, + laneKey: "backlog" as never, + }, + }); + + const deltas = Array.from(yield* Fiber.join(deltasFiber)); + assert.equal(deltas[0]?.ticketId, "t-1"); + assert.equal(deltas[0]?.boardId, "b-1"); + assert.equal(deltas[0]?.title, "Board delta"); + assert.equal(deltas[0]?.currentLaneKey, "backlog"); + assert.equal(deltas[0]?.status, "idle"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts b/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts new file mode 100644 index 00000000000..429cf04f280 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts @@ -0,0 +1,23 @@ +import type { BoardTicketView } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +import { + WorkflowBoardEvents, + type WorkflowBoardEventsShape, +} from "../Services/WorkflowBoardEvents.ts"; + +const make = Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + + const publish: WorkflowBoardEventsShape["publish"] = (ticket) => + PubSub.publish(pubsub, ticket).pipe(Effect.asVoid); + const stream: WorkflowBoardEventsShape["stream"] = (boardId) => + Stream.fromPubSub(pubsub).pipe(Stream.filter((ticket) => ticket.boardId === boardId)); + + return { publish, stream } satisfies WorkflowBoardEventsShape; +}); + +export const WorkflowBoardEventsLive = Layer.effect(WorkflowBoardEvents, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts index a48bffaf677..b144bfcebb2 100644 --- a/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts @@ -1,21 +1,48 @@ +import type { BoardId, BoardTicketView, LaneKey, TicketId, TicketStatus } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; import { WorkflowEventCommitter, type WorkflowEventCommitterShape, } from "../Services/WorkflowEventCommitter.ts"; import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; const make = Effect.gen(function* () { const store = yield* WorkflowEventStore; const pipeline = yield* WorkflowProjectionPipeline; + const readModel = yield* WorkflowReadModel; + + const getOptionalServices = Effect.context().pipe( + Effect.map((context) => ({ + boardEvents: Context.getOption( + context as Context.Context, + WorkflowBoardEvents, + ), + })), + ); const commit: WorkflowEventCommitterShape["commit"] = (event) => Effect.gen(function* () { const persisted = yield* store.append(event); yield* pipeline.projectEvent(persisted); + const detail = yield* readModel.getTicketDetail(persisted.ticketId); + const { boardEvents } = yield* getOptionalServices; + if (detail && Option.isSome(boardEvents)) { + const ticket = detail.ticket; + yield* boardEvents.value.publish({ + ticketId: ticket.ticketId as TicketId, + boardId: ticket.boardId as BoardId, + title: ticket.title, + currentLaneKey: ticket.currentLaneKey as LaneKey, + status: ticket.status as TicketStatus, + } satisfies BoardTicketView); + } }); return { commit } satisfies WorkflowEventCommitterShape; diff --git a/apps/server/src/workflow/Services/WorkflowBoardEvents.ts b/apps/server/src/workflow/Services/WorkflowBoardEvents.ts new file mode 100644 index 00000000000..136f6e54fe8 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardEvents.ts @@ -0,0 +1,14 @@ +import type { BoardId, BoardTicketView } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +export interface WorkflowBoardEventsShape { + readonly publish: (ticket: BoardTicketView) => Effect.Effect; + readonly stream: (boardId: BoardId) => Stream.Stream; +} + +export class WorkflowBoardEvents extends Context.Service< + WorkflowBoardEvents, + WorkflowBoardEventsShape +>()("t3/workflow/Services/WorkflowBoardEvents") {} diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts index 485a8ad457e..0aefd8c3f1e 100644 --- a/apps/server/src/workflow/WorkflowRuntimeLive.ts +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -12,6 +12,7 @@ import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecuto import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; import { TicketCheckpointServiceLive } from "./Layers/TicketCheckpointService.ts"; import { TurnProjectionPortLive, TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; +import { WorkflowBoardEventsLive } from "./Layers/WorkflowBoardEvents.ts"; import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; @@ -29,6 +30,7 @@ export const WorkflowRuntimeCoreLive = Layer.mergeAll( Layer.provideMerge(SetupRunServiceLive), Layer.provideMerge(WorktreeLeaseServiceLive), Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorkflowBoardEventsLive), Layer.provideMerge(WorkflowEventCommitterLive), Layer.provideMerge(BoardRegistryLive), Layer.provideMerge(ApprovalGateLive), From ef651937b5b45b193c76dce3c35fcfac0f4edb39 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:42:13 -0400 Subject: [PATCH 046/295] feat(workflow): wire workflow.* RPC handlers + runtime + recovery at startup Constraint: M5 Task 4 requires workflow RPC handlers in ws.ts, runtime services in the server graph, and startup recovery after workflow services are available. Rejected: Inlining all workflow RPC mapping in ws.ts | a pure handler module keeps behavior testable against stubs while ws.ts owns auth and instrumentation. Confidence: high Scope-risk: moderate Directive: Keep workflow RPC behavior behind WorkflowRpcHandlers and only resolve/instrument services in ws.ts. Tested: pnpm --dir apps/server exec vp test run src/workflow/Layers/WorkflowRpcHandlers.test.ts; pnpm --filter t3 typecheck Not-tested: Full milestone vp check and all workflow package tests deferred until M5 Definition of Done. --- apps/server/src/server.test.ts | 36 +++ apps/server/src/server.ts | 20 +- apps/server/src/serverRuntimeStartup.ts | 14 + .../Layers/WorkflowRpcHandlers.test.ts | 95 +++++++ .../workflow/Layers/WorkflowRpcHandlers.ts | 246 ++++++++++++++++++ .../src/workflow/WorkflowRuntimeLive.ts | 22 ++ apps/server/src/ws.ts | 78 ++++++ 7 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d061578ca68..ed21fa09b89 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -136,6 +136,12 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; +import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; +import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; +import { WorkflowFileLoader } from "./workflow/Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "./workflow/Services/WorkflowReadModel.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -535,6 +541,35 @@ const buildAppUnderTest = (options?: { ...options.layers.vcsStatusBroadcaster, }) : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); + const workflowRouteServicesLayer = Layer.mergeAll( + Layer.mock(WorkflowEngine)({ + createTicket: () => Effect.die("unused workflow createTicket"), + moveTicket: () => Effect.die("unused workflow moveTicket"), + runLane: () => Effect.die("unused workflow runLane"), + resolveApproval: () => Effect.die("unused workflow resolveApproval"), + }), + Layer.mock(WorkflowReadModel)({ + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + }), + Layer.mock(BoardRegistry)({ + register: () => Effect.die("unused workflow board register"), + getDefinition: () => Effect.succeed(null), + getLane: () => Effect.succeed(null), + }), + Layer.mock(TicketDiffQuery)({ + getTicketDiff: () => Effect.die("unused workflow ticket diff"), + }), + Layer.mock(WorkflowBoardEvents)({ + publish: () => Effect.void, + stream: () => Stream.empty, + }), + Layer.mock(WorkflowFileLoader)({ + loadAndRegister: () => Effect.die("unused workflow file load"), + }), + ); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -719,6 +754,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + Layer.provide(workflowRouteServicesLayer), ); const appLayer = servedRoutesLayer.pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98bef90bb2e..6e6182cefcb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -17,6 +17,7 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepositoryLive } from "./persistence/Layers/ProjectionTurns.ts"; import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -76,6 +77,7 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { WorkflowServerRuntimeLive } from "./workflow/WorkflowRuntimeLive.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -259,13 +261,26 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const WorkflowRuntimeLayerLive = WorkflowServerRuntimeLive.pipe( + Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(GitLayerLive), + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), +); + +const RuntimeCoreEngineLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(WorkflowRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), @@ -293,6 +308,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), +); + +const RuntimeCoreDependenciesLive = RuntimeCoreEngineLive.pipe( Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cde308ffe42..189008d2833 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,7 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; +import { WorkflowRecovery } from "./workflow/Services/WorkflowRecovery.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -289,6 +290,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; + const workflowRecovery = yield* WorkflowRecovery; const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; @@ -337,6 +339,18 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { }), ); + yield* Effect.logDebug("startup phase: recovering workflow runtime"); + yield* runStartupPhase( + "workflow.recover", + workflowRecovery.recover().pipe( + Effect.catch((cause) => + Effect.logWarning("workflow recovery failed during startup", { + cause, + }), + ), + ), + ); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts new file mode 100644 index 00000000000..d0a69f3e99e --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -0,0 +1,95 @@ +import { assert, it } from "@effect/vitest"; +import { + BoardId, + LaneKey, + TicketId, + WORKFLOW_WS_METHODS, + type WorkflowDefinition, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { workflowRpcHandlers } from "./WorkflowRpcHandlers.ts"; + +it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => + Effect.gen(function* () { + const boardId = BoardId.make("board-1"); + const backlog = LaneKey.make("backlog"); + const definition = { + name: "Delivery", + lanes: [{ key: backlog, name: "Backlog", entry: "manual" }], + } satisfies WorkflowDefinition; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.succeed(TicketId.make("ticket-created")), + moveTicket: () => Effect.void, + runLane: () => Effect.void, + resolveApproval: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-1", + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 2, + }), + listTickets: () => + Effect.succeed([ + { + ticketId: "ticket-1", + boardId, + title: "Existing", + currentLaneKey: "backlog", + currentLaneEntryToken: null, + status: "idle", + }, + ]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + }, + boardRegistry: { + register: () => Effect.die("unused"), + getDefinition: () => Effect.succeed(definition), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + loadAndRegister: () => Effect.succeed(boardId), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const created = yield* handlers[WORKFLOW_WS_METHODS.createTicket]({ + boardId, + title: "New ticket", + initialLane: backlog, + }); + const streamItems = Array.from( + yield* handlers[WORKFLOW_WS_METHODS.subscribeBoard]({ boardId }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ); + + assert.deepEqual(created, { ticketId: "ticket-created" }); + assert.equal(streamItems[0]?.kind, "snapshot"); + if (streamItems[0]?.kind === "snapshot") { + assert.equal(streamItems[0].snapshot.board.name, "Delivery"); + assert.equal(streamItems[0].snapshot.tickets[0]?.title, "Existing"); + } + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts new file mode 100644 index 00000000000..9aabd05247c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -0,0 +1,246 @@ +import type { + BoardId, + BoardSnapshot, + BoardTicketView, + EnvironmentAuthorizationError, + LaneKey, + StepRunId, + StepRunStatus, + TicketId, + TicketStatus, + WorkflowStepRunView, + WorkflowTicketDetailView, +} from "@t3tools/contracts"; +import { WORKFLOW_WS_METHODS, WorkflowRpcError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import type { BoardRegistryShape } from "../Services/BoardRegistry.ts"; +import type { WorkflowBoardEventsShape } from "../Services/WorkflowBoardEvents.ts"; +import type { WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import type { WorkflowFileLoaderShape } from "../Services/WorkflowFileLoader.ts"; +import type { + StepRunRow, + TicketRow, + WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; +import type { TicketDiffQueryShape } from "../Services/TicketDiffQuery.ts"; + +export interface TicketWorktreeResolverShape { + readonly resolveForTicket: ( + ticketId: TicketId, + ) => Effect.Effect<{ readonly cwd: string; readonly baseRef: string }, WorkflowRpcError>; +} + +interface WorkflowCreateTicketInput { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: LaneKey; +} + +interface WorkflowRpcHandlerDeps { + readonly engine: WorkflowEngineShape; + readonly readModel: WorkflowReadModelShape; + readonly boardRegistry: BoardRegistryShape; + readonly ticketDiff: TicketDiffQueryShape; + readonly ticketWorktrees: TicketWorktreeResolverShape; + readonly boardEvents: WorkflowBoardEventsShape; + readonly fileLoader: WorkflowFileLoaderShape; + readonly observeRpcEffect: ( + method: string, + effect: Effect.Effect, + traceAttributes?: Readonly>, + ) => Effect.Effect; + readonly observeRpcStreamEffect: ( + method: string, + effect: Effect.Effect, EffectError, EffectContext>, + traceAttributes?: Readonly>, + ) => Stream.Stream< + A, + StreamError | EffectError | EnvironmentAuthorizationError, + StreamContext | EffectContext + >; +} + +const toBoardTicketView = (ticket: TicketRow): BoardTicketView => ({ + ticketId: ticket.ticketId as TicketId, + boardId: ticket.boardId as BoardId, + title: ticket.title, + currentLaneKey: ticket.currentLaneKey as LaneKey, + status: ticket.status as TicketStatus, +}); + +const toStepRunView = (step: StepRunRow): WorkflowStepRunView => ({ + stepRunId: step.stepRunId as never, + stepKey: step.stepKey as never, + stepType: step.stepType as "agent" | "approval", + status: step.status as StepRunStatus, + waitingReason: step.waitingReason, +}); + +const workflowRpcError = (message: string, cause?: unknown) => + new WorkflowRpcError({ + message, + ...(cause === undefined ? {} : { cause }), + }); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + workflowRpcError(message, cause); + +const boardSnapshot = ( + deps: Pick, + boardId: BoardId, +): Effect.Effect => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow board", cause))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + const definition = yield* deps.boardRegistry.getDefinition(boardId); + if (!definition) { + return yield* workflowRpcError(`Workflow board definition ${boardId} was not found`); + } + + const tickets = yield* deps.readModel + .listTickets(boardId) + .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow tickets", cause))); + + return { + board: { + boardId, + name: board.name, + lanes: definition.lanes.map((lane) => ({ + key: lane.key, + name: lane.name, + entry: lane.entry, + ...(lane.terminal === undefined ? {} : { terminal: lane.terminal }), + })), + }, + tickets: tickets.map(toBoardTicketView), + } satisfies BoardSnapshot; + }); + +const ticketDetail = ( + deps: Pick, + ticketId: TicketId, +): Effect.Effect => + Effect.gen(function* () { + const detail = yield* deps.readModel + .getTicketDetail(ticketId) + .pipe( + Effect.mapError((cause) => + workflowRpcError("Failed to load workflow ticket detail", cause), + ), + ); + if (!detail) { + return yield* workflowRpcError(`Workflow ticket ${ticketId} was not found`); + } + + return { + ticket: toBoardTicketView(detail.ticket), + steps: detail.steps.map(toStepRunView), + } satisfies WorkflowTicketDetailView; + }); + +export const workflowRpcHandlers = (deps: WorkflowRpcHandlerDeps) => ({ + [WORKFLOW_WS_METHODS.registerBoardFromFile]: (input: { + readonly boardId: Parameters[0]["boardId"]; + readonly projectId: Parameters[0]["projectId"]; + readonly filePath: string; + readonly repoRoot: string; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.registerBoardFromFile, + deps.fileLoader.loadAndRegister(input).pipe(Effect.map((boardId) => ({ boardId }))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.getBoard]: (input: { readonly boardId: BoardId }) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoard, boardSnapshot(deps, input.boardId), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.subscribeBoard]: (input: { readonly boardId: BoardId }) => + deps.observeRpcStreamEffect( + WORKFLOW_WS_METHODS.subscribeBoard, + boardSnapshot(deps, input.boardId).pipe( + Effect.map((snapshot) => + Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + deps.boardEvents + .stream(input.boardId) + .pipe(Stream.map((ticket) => ({ kind: "ticket" as const, ticket }))), + ), + ), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.createTicket]: (input: WorkflowCreateTicketInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createTicket, + deps.engine + .createTicket({ + boardId: input.boardId, + title: input.title, + initialLane: input.initialLane, + ...(input.description === undefined ? {} : { description: input.description }), + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to create workflow ticket")), + Effect.map((ticketId) => ({ ticketId })), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.moveTicket]: (input: { + readonly ticketId: TicketId; + readonly toLane: LaneKey; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.moveTicket, + deps.engine + .moveTicket(input.ticketId, input.toLane) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to move workflow ticket"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.runLane]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.runLane, + deps.engine + .runLane(input.ticketId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to run workflow lane"))), + { + "rpc.aggregate": "workflow", + }, + ), + [WORKFLOW_WS_METHODS.resolveApproval]: (input: { + readonly stepRunId: StepRunId; + readonly approved: boolean; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.resolveApproval, + deps.engine + .resolveApproval(input.stepRunId, input.approved) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow approval"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.getTicketDetail]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getTicketDetail, ticketDetail(deps, input.ticketId), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getTicketDiff]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getTicketDiff, + deps.ticketWorktrees + .resolveForTicket(input.ticketId) + .pipe( + Effect.flatMap(({ cwd, baseRef }) => + deps.ticketDiff + .getTicketDiff(input.ticketId, cwd, baseRef) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow ticket diff"))), + ), + ), + { "rpc.aggregate": "workflow" }, + ), +}); diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts index 0aefd8c3f1e..6f54723267e 100644 --- a/apps/server/src/workflow/WorkflowRuntimeLive.ts +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -11,10 +11,16 @@ import { ProviderResponsePortLive } from "./Layers/ProviderResponsePort.ts"; import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecutor.ts"; import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; import { TicketCheckpointServiceLive } from "./Layers/TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./Layers/TicketDiffQuery.ts"; import { TurnProjectionPortLive, TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; import { WorkflowBoardEventsLive } from "./Layers/WorkflowBoardEvents.ts"; import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { + WorkflowFileLoaderLive, + WorkflowFilePortLive, + WorkflowProviderInstancePortLive, +} from "./Layers/WorkflowFileLoader.ts"; import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; import { WorkflowRecoveryLive } from "./Layers/WorkflowRecovery.ts"; import { WorktreeLeaseServiceLive } from "./Layers/WorktreeLeaseService.ts"; @@ -46,3 +52,19 @@ export const WorkflowRuntimeLive = WorkflowRuntimeCoreLive.pipe( Layer.provideMerge(WorktreePortLive), Layer.provideMerge(ProviderResponsePortLive), ); + +export const WorkflowRpcSupportLive = Layer.mergeAll( + WorkflowFileLoaderLive, + TicketDiffQueryLive, +).pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge(WorkflowProviderInstancePortLive), + Layer.provideMerge(WorktreeDiffPortLive), +); + +export const WorkflowServerRuntimeLive = WorkflowRuntimeLive.pipe( + Layer.provideMerge(WorkflowRpcSupportLive), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..f286623c061 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,6 +13,8 @@ import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, AuthReviewWriteScope, AuthRelayWriteScope, AuthTerminalOperateScope, @@ -41,10 +43,13 @@ import { FilesystemBrowseError, EnvironmentAuthorizationError, ThreadId, + type TicketId, type TerminalAttachStreamEvent, type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + WORKFLOW_WS_METHODS, + WorkflowRpcError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -99,6 +104,14 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; +import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; +import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; +import { WorkflowFileLoader } from "./workflow/Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "./workflow/Services/WorkflowReadModel.ts"; +import { workflowRpcHandlers } from "./workflow/Layers/WorkflowRpcHandlers.ts"; +import { ticketBaseRef } from "./workflow/ticketRefs.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); @@ -137,6 +150,15 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [WORKFLOW_WS_METHODS.subscribeBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getTicketDetail, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getTicketDiff, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.moveTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.runLane, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.resolveApproval, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.registerBoardFromFile, AuthWorkflowOperateScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -267,6 +289,12 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const workflowEngine = yield* WorkflowEngine; + const workflowReadModel = yield* WorkflowReadModel; + const workflowBoardRegistry = yield* BoardRegistry; + const workflowTicketDiff = yield* TicketDiffQuery; + const workflowBoardEvents = yield* WorkflowBoardEvents; + const workflowFileLoader = yield* WorkflowFileLoader; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -759,7 +787,57 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + const ticketWorktrees = { + resolveForTicket: (ticketId: TicketId) => + Effect.gen(function* () { + const refName = `workflow/${ticketId as string}`; + const refs = yield* gitWorkflow + .listRefs({ + cwd: config.cwd, + query: refName, + limit: 100, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "Failed to resolve workflow ticket worktree refs", + cause, + }), + ), + ); + const ref = refs.refs.find( + (candidate) => + candidate.name === refName && + candidate.isRemote !== true && + candidate.worktreePath !== null, + ); + if (!ref?.worktreePath) { + return yield* new WorkflowRpcError({ + message: `Workflow ticket ${ticketId} does not have an attached worktree`, + }); + } + return { + cwd: ref.worktreePath, + baseRef: ticketBaseRef(ticketId), + }; + }), + }; + + const workflowHandlers = workflowRpcHandlers({ + engine: workflowEngine, + readModel: workflowReadModel, + boardRegistry: workflowBoardRegistry, + ticketDiff: workflowTicketDiff, + ticketWorktrees, + boardEvents: workflowBoardEvents, + fileLoader: workflowFileLoader, + observeRpcEffect, + observeRpcStreamEffect, + }); + return WsRpcGroup.of({ + ...workflowHandlers, [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, From 9ed8a27a4fa6533503254df1a367cf8019c190e3 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:45:53 -0400 Subject: [PATCH 047/295] feat(web): board state slice + subscription wiring Constraint: M5 Task 5 requires the workflow board stream to reach web state through client-runtime and EnvironmentApi. Rejected: Keeping workflow RPC access as flat protocol calls in web code | the existing app surface uses namespaced EnvironmentApi clients. Confidence: high Scope-risk: moderate Directive: Keep board stream reduction in boardState.ts and route live subscription updates through the store action. Tested: pnpm --dir apps/web exec vp test run src/workflow/boardState.test.ts; pnpm --filter @t3tools/contracts typecheck; pnpm --filter @t3tools/client-runtime typecheck; pnpm --filter @t3tools/web typecheck Not-tested: Full milestone vp check and all package tests deferred until M5 Definition of Done. --- apps/web/src/components/ChatView.browser.tsx | 2 + apps/web/src/environmentApi.ts | 12 ++++ apps/web/src/environmentGrouping.test.ts | 1 + .../service.threadSubscriptions.test.ts | 11 +++ apps/web/src/store.test.ts | 3 + apps/web/src/store.ts | 27 ++++++++ apps/web/src/workflow/boardRpc.ts | 32 +++++++++ apps/web/src/workflow/boardState.test.ts | 43 ++++++++++++ apps/web/src/workflow/boardState.ts | 68 +++++++++++++++++++ packages/client-runtime/src/wsRpcClient.ts | 37 ++++++++++ packages/contracts/src/ipc.ts | 42 +++++++++++- 11 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/workflow/boardRpc.ts create mode 100644 apps/web/src/workflow/boardState.test.ts create mode 100644 apps/web/src/workflow/boardState.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 69e0e514061..526bde1f8fd 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -253,6 +253,7 @@ function createMockEnvironmentApi(input: { vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], review: {} as EnvironmentApi["review"], + workflow: {} as EnvironmentApi["workflow"], orchestration: { dispatchCommand: input.dispatchCommand, getTurnDiff: (() => { @@ -1763,6 +1764,7 @@ describe("ChatView timeline estimator parity (full app)", () => { useStore.setState({ activeEnvironmentId: null, environmentStateById: {}, + boardStateById: {}, }); useUiStateStore.setState({ projectExpandedById: {}, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index bdb2e793069..f079a2fec5d 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -58,6 +58,18 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { subscribeThread: (input, callback, options) => rpcClient.orchestration.subscribeThread(input, callback, options), }, + workflow: { + registerBoardFromFile: rpcClient.workflow.registerBoardFromFile, + getBoard: rpcClient.workflow.getBoard, + subscribeBoard: (input, callback, options) => + rpcClient.workflow.subscribeBoard(input, callback, options), + createTicket: rpcClient.workflow.createTicket, + moveTicket: rpcClient.workflow.moveTicket, + runLane: rpcClient.workflow.runLane, + resolveApproval: rpcClient.workflow.resolveApproval, + getTicketDetail: rpcClient.workflow.getTicketDetail, + getTicketDiff: rpcClient.workflow.getTicketDiff, + }, }; } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index ae879c671f5..a842910276a 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -217,6 +217,7 @@ function makeFixtureState(): AppState { [primaryEnvId]: primaryEnvState, [remoteEnvId]: remoteEnvState, }, + boardStateById: {}, }; } diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 675a4868032..e41e40bc087 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -138,6 +138,17 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { review: { getDiffPreview: vi.fn(), }, + workflow: { + registerBoardFromFile: vi.fn(), + getBoard: vi.fn(), + subscribeBoard: vi.fn(() => () => undefined), + createTicket: vi.fn(), + moveTicket: vi.fn(), + runLane: vi.fn(), + resolveApproval: vi.fn(), + getTicketDetail: vi.fn(), + getTicketDiff: vi.fn(), + }, server: { getConfig: vi.fn(), refreshProviders: vi.fn(), diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index cad78ab9d35..c1494b9ebd0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -56,6 +56,7 @@ function withActiveEnvironmentState( return { activeEnvironmentId, environmentStateById, + boardStateById: {}, }; } @@ -262,6 +263,7 @@ describe("environment state removal", () => { [remoteEnvironmentId]: removedState, [localEnvironmentId]: keptState, }, + boardStateById: {}, }; const next = removeEnvironmentState(state, remoteEnvironmentId); @@ -421,6 +423,7 @@ describe("setThreadBranch", () => { [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), }, + boardStateById: {}, }; const next = setThreadBranch( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 7d995b5ea75..240b17ef560 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,6 @@ import type { + BoardId, + BoardStreamItem, EnvironmentId, MessageId, OrchestrationCheckpointSummary, @@ -37,6 +39,11 @@ import { import { resolveEnvironmentHttpUrl } from "./environments/runtime"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; +import { + applyBoardStreamItem as reduceBoardStreamItem, + emptyBoardState, + type BoardState, +} from "./workflow/boardState"; const isProviderDriverKindValue = Schema.is(ProviderDriverKind); export interface EnvironmentState { @@ -99,6 +106,7 @@ export interface EnvironmentState { export interface AppState { activeEnvironmentId: EnvironmentId | null; environmentStateById: Record; + boardStateById: Record; } const initialEnvironmentState: EnvironmentState = { @@ -124,6 +132,7 @@ const initialEnvironmentState: EnvironmentState = { const initialState: AppState = { activeEnvironmentId: null, environmentStateById: {}, + boardStateById: {}, }; const MAX_THREAD_MESSAGES = 2_000; @@ -1955,6 +1964,21 @@ export function setThreadBranch( return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); } +export function applyWorkflowBoardStreamItem( + state: AppState, + boardId: BoardId, + item: BoardStreamItem, +): AppState { + const key = boardId as string; + return { + ...state, + boardStateById: { + ...state.boardStateById, + [key]: reduceBoardStreamItem(state.boardStateById[key] ?? emptyBoardState, item), + }, + }; +} + interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; removeEnvironmentState: (environmentId: EnvironmentId) => void; @@ -1975,6 +1999,7 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; + applyBoardStreamItem: (boardId: BoardId, item: BoardStreamItem) => void; } export const useStore = create((set) => ({ @@ -1996,4 +2021,6 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadRef, branch, worktreePath) => set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), + applyBoardStreamItem: (boardId, item) => + set((state) => applyWorkflowBoardStreamItem(state, boardId, item)), })); diff --git a/apps/web/src/workflow/boardRpc.ts b/apps/web/src/workflow/boardRpc.ts new file mode 100644 index 00000000000..c089af19e9e --- /dev/null +++ b/apps/web/src/workflow/boardRpc.ts @@ -0,0 +1,32 @@ +import type { BoardId, EnvironmentApi, LaneKey, StepRunId, TicketId } from "@t3tools/contracts"; + +import { useStore } from "../store"; + +interface SubscriptionOptions { + readonly onResubscribe?: () => void; +} + +export const subscribeBoard = ( + api: EnvironmentApi, + boardId: BoardId, + options?: SubscriptionOptions, +): (() => void) => + api.workflow.subscribeBoard( + { boardId }, + (item) => useStore.getState().applyBoardStreamItem(boardId, item), + options, + ); + +export const createTicket = ( + api: EnvironmentApi, + input: Parameters[0], +) => api.workflow.createTicket(input); + +export const moveTicket = (api: EnvironmentApi, ticketId: TicketId, toLane: LaneKey) => + api.workflow.moveTicket({ ticketId, toLane }); + +export const resolveApproval = (api: EnvironmentApi, stepRunId: StepRunId, approved: boolean) => + api.workflow.resolveApproval({ stepRunId, approved }); + +export const getTicketDiff = (api: EnvironmentApi, ticketId: TicketId) => + api.workflow.getTicketDiff({ ticketId }); diff --git a/apps/web/src/workflow/boardState.test.ts b/apps/web/src/workflow/boardState.test.ts new file mode 100644 index 00000000000..8f512d3610f --- /dev/null +++ b/apps/web/src/workflow/boardState.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { applyBoardStreamItem, emptyBoardState } from "./boardState.ts"; + +describe("boardState", () => { + it("applies a snapshot then a ticket delta", () => { + let state = applyBoardStreamItem(emptyBoardState, { + kind: "snapshot", + snapshot: { + board: { + boardId: "b-1", + name: "Delivery", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }, + tickets: [ + { + ticketId: "t-1", + boardId: "b-1", + title: "X", + currentLaneKey: "backlog", + status: "idle", + }, + ], + }, + } as never); + expect(state.ticketIds).toEqual(["t-1"]); + + state = applyBoardStreamItem(state, { + kind: "ticket", + ticket: { + ticketId: "t-1", + boardId: "b-1", + title: "X", + currentLaneKey: "done", + status: "done", + }, + } as never); + expect(state.ticketById["t-1"]?.currentLaneKey).toBe("done"); + }); +}); diff --git a/apps/web/src/workflow/boardState.ts b/apps/web/src/workflow/boardState.ts new file mode 100644 index 00000000000..39211723f3a --- /dev/null +++ b/apps/web/src/workflow/boardState.ts @@ -0,0 +1,68 @@ +import type { BoardStreamItem } from "@t3tools/contracts"; + +export interface BoardState { + readonly boardId: string | null; + readonly boardName: string; + readonly lanes: ReadonlyArray<{ + readonly key: string; + readonly name: string; + readonly entry: string; + readonly terminal?: boolean | undefined; + }>; + readonly ticketIds: ReadonlyArray; + readonly ticketById: Record< + string, + { + readonly ticketId: string; + readonly title: string; + readonly currentLaneKey: string; + readonly status: string; + } + >; +} + +export const emptyBoardState: BoardState = { + boardId: null, + boardName: "", + lanes: [], + ticketIds: [], + ticketById: {}, +}; + +export const applyBoardStreamItem = (state: BoardState, item: BoardStreamItem): BoardState => { + if (item.kind === "snapshot") { + const ticketById: BoardState["ticketById"] = {}; + for (const ticket of item.snapshot.tickets) { + ticketById[ticket.ticketId] = { + ticketId: ticket.ticketId, + title: ticket.title, + currentLaneKey: ticket.currentLaneKey, + status: ticket.status, + }; + } + + return { + boardId: item.snapshot.board.boardId, + boardName: item.snapshot.board.name, + lanes: item.snapshot.board.lanes.map((lane) => ({ ...lane })), + ticketIds: item.snapshot.tickets.map((ticket) => ticket.ticketId), + ticketById, + }; + } + + const ticket = item.ticket; + const exists = state.ticketById[ticket.ticketId] !== undefined; + return { + ...state, + ticketIds: exists ? state.ticketIds : [...state.ticketIds, ticket.ticketId], + ticketById: { + ...state.ticketById, + [ticket.ticketId]: { + ticketId: ticket.ticketId, + title: ticket.title, + currentLaneKey: ticket.currentLaneKey, + status: ticket.status, + }, + }, + }; +}; diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b2..220931ea62d 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -9,6 +9,7 @@ import { type ServerSettingsPatch, type VcsStatusResult, type VcsStatusStreamEvent, + WORKFLOW_WS_METHODS, WS_METHODS, } from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; @@ -168,6 +169,19 @@ export interface WsRpcClient { readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; + readonly workflow: { + readonly registerBoardFromFile: RpcUnaryMethod< + typeof WORKFLOW_WS_METHODS.registerBoardFromFile + >; + readonly getBoard: RpcUnaryMethod; + readonly subscribeBoard: RpcInputStreamMethod; + readonly createTicket: RpcUnaryMethod; + readonly moveTicket: RpcUnaryMethod; + readonly runLane: RpcUnaryMethod; + readonly resolveApproval: RpcUnaryMethod; + readonly getTicketDetail: RpcUnaryMethod; + readonly getTicketDiff: RpcUnaryMethod; + }; } export interface CreateWsRpcClientOptions { @@ -372,5 +386,28 @@ export function createWsRpcClient( subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), ), }, + workflow: { + registerBoardFromFile: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.registerBoardFromFile](input)), + getBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getBoard](input)), + subscribeBoard: (input, listener, options) => + transport.subscribe( + (client) => client[WORKFLOW_WS_METHODS.subscribeBoard](input), + listener, + subscriptionOptions(options, WORKFLOW_WS_METHODS.subscribeBoard), + ), + createTicket: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.createTicket](input)), + moveTicket: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.moveTicket](input)), + runLane: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.runLane](input)), + resolveApproval: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.resolveApproval](input)), + getTicketDetail: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getTicketDetail](input)), + getTicketDiff: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.getTicketDiff](input)), + }, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 56f929f7def..e66932ac84c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -65,7 +65,7 @@ import type { OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, } from "./orchestration.ts"; -import { EnvironmentId } from "./baseSchemas.ts"; +import { EnvironmentId, type ProjectId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; @@ -80,6 +80,16 @@ import type { SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; +import type { + BoardId, + BoardSnapshot, + BoardStreamItem, + LaneKey, + StepRunId, + TicketDiff, + TicketId, + WorkflowTicketDetailView, +} from "./workflow.ts"; export interface ContextMenuItem { id: T; @@ -611,4 +621,34 @@ export interface EnvironmentApi { }, ) => () => void; }; + workflow: { + registerBoardFromFile: (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly filePath: string; + readonly repoRoot: string; + }) => Promise<{ readonly boardId: BoardId }>; + getBoard: (input: { readonly boardId: BoardId }) => Promise; + subscribeBoard: ( + input: { readonly boardId: BoardId }, + callback: (event: BoardStreamItem) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + createTicket: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: LaneKey; + }) => Promise<{ readonly ticketId: TicketId }>; + moveTicket: (input: { readonly ticketId: TicketId; readonly toLane: LaneKey }) => Promise; + runLane: (input: { readonly ticketId: TicketId }) => Promise; + resolveApproval: (input: { + readonly stepRunId: StepRunId; + readonly approved: boolean; + }) => Promise; + getTicketDetail: (input: { readonly ticketId: TicketId }) => Promise; + getTicketDiff: (input: { readonly ticketId: TicketId }) => Promise; + }; } From fad4b36d8d438edec1bd214eb36c280470e9b80a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:51:06 -0400 Subject: [PATCH 048/295] feat(web): board view with dnd-kit lanes and ticket cards Constraint: Task 6 required dnd-kit lanes/cards plus route wiring after a failing component test.\nRejected: Adding ticket drawer behavior here | Task 7 owns drill-in UI and diff controls.\nConfidence: high\nScope-risk: narrow\nDirective: Keep card-over-card drop resolution covered by the pure resolver test when changing dnd behavior.\nTested: pnpm --dir apps/web exec vp test run src/components/board/BoardView.test.tsx; pnpm --filter @t3tools/web typecheck; pnpm exec vp check\nNot-tested: Live browser drag smoke against a running backend board. --- .../src/components/board/BoardView.test.tsx | 47 ++++++++ apps/web/src/components/board/BoardView.tsx | 92 +++++++++++++++ apps/web/src/components/board/LaneColumn.tsx | 48 ++++++++ apps/web/src/components/board/TicketCard.tsx | 106 ++++++++++++++++++ apps/web/src/routeTree.gen.ts | 21 ++++ .../src/routes/_chat.$environmentId.board.tsx | 82 ++++++++++++++ 6 files changed, 396 insertions(+) create mode 100644 apps/web/src/components/board/BoardView.test.tsx create mode 100644 apps/web/src/components/board/BoardView.tsx create mode 100644 apps/web/src/components/board/LaneColumn.tsx create mode 100644 apps/web/src/components/board/TicketCard.tsx create mode 100644 apps/web/src/routes/_chat.$environmentId.board.tsx diff --git a/apps/web/src/components/board/BoardView.test.tsx b/apps/web/src/components/board/BoardView.test.tsx new file mode 100644 index 00000000000..4c745eb1273 --- /dev/null +++ b/apps/web/src/components/board/BoardView.test.tsx @@ -0,0 +1,47 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { BoardView, resolveBoardDropLaneKey, type BoardViewState } from "./BoardView"; + +const boardState = { + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + ticketIds: ["ticket-1", "ticket-2"], + ticketById: { + "ticket-1": { + ticketId: "ticket-1", + title: "Add board lanes", + currentLaneKey: "backlog", + status: "waiting_on_user", + }, + "ticket-2": { + ticketId: "ticket-2", + title: "Ship milestone", + currentLaneKey: "done", + status: "done", + }, + }, +} satisfies BoardViewState; + +describe("BoardView", () => { + it("renders lanes, ticket cards, and status badges", () => { + const markup = renderToStaticMarkup( + {}} onOpen={() => {}} />, + ); + + expect(markup).toContain("Backlog"); + expect(markup).toContain("Done"); + expect(markup).toContain("Add board lanes"); + expect(markup).toContain("Ship milestone"); + expect(markup).toContain("waiting on you"); + expect(markup).toContain("done"); + }); + + it("resolves a card drop target back to the destination lane", () => { + expect(resolveBoardDropLaneKey(boardState, "ticket-1", "lane:done")).toBe("done"); + expect(resolveBoardDropLaneKey(boardState, "ticket-1", "ticket-2")).toBe("done"); + expect(resolveBoardDropLaneKey(boardState, "ticket-1", "ticket-1")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx new file mode 100644 index 00000000000..a1efca7fb82 --- /dev/null +++ b/apps/web/src/components/board/BoardView.tsx @@ -0,0 +1,92 @@ +import { + closestCorners, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; + +import { LaneColumn, type LaneColumnView } from "./LaneColumn"; + +export interface BoardViewTicket { + readonly ticketId: string; + readonly title: string; + readonly currentLaneKey: string; + readonly status: string; +} + +export interface BoardViewState { + readonly lanes: ReadonlyArray; + readonly ticketIds: ReadonlyArray; + readonly ticketById: Record; +} + +export function resolveBoardDropLaneKey( + state: BoardViewState, + ticketId: string, + overId: string | null, +): string | null { + if (!overId) { + return null; + } + + const targetLaneKey = overId.startsWith("lane:") + ? overId.slice("lane:".length) + : state.ticketById[overId]?.currentLaneKey; + const currentLaneKey = state.ticketById[ticketId]?.currentLaneKey; + + if (!targetLaneKey || targetLaneKey === currentLaneKey) { + return null; + } + + return targetLaneKey; +} + +const ticketsForLane = (state: BoardViewState, laneKey: string): ReadonlyArray => + state.ticketIds + .map((ticketId) => state.ticketById[ticketId]) + .filter( + (ticket): ticket is BoardViewTicket => + ticket !== undefined && ticket.currentLaneKey === laneKey, + ); + +export function BoardView({ + state, + onMove, + onOpen, +}: { + readonly state: BoardViewState; + readonly onMove: (ticketId: string, toLane: string) => void; + readonly onOpen: (id: string) => void; +}) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + const handleDragEnd = (event: DragEndEvent) => { + const ticketId = String(event.active.id); + const overId = event.over ? String(event.over.id) : null; + const toLane = resolveBoardDropLaneKey(state, ticketId, overId); + + if (toLane) { + onMove(ticketId, toLane); + } + }; + + return ( + +
+ {state.lanes.map((lane) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/board/LaneColumn.tsx b/apps/web/src/components/board/LaneColumn.tsx new file mode 100644 index 00000000000..a9931b1b0c6 --- /dev/null +++ b/apps/web/src/components/board/LaneColumn.tsx @@ -0,0 +1,48 @@ +import { useDroppable } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +import { TicketCard, type TicketCardView } from "./TicketCard"; + +export interface LaneColumnView { + readonly key: string; + readonly name: string; + readonly entry: string; + readonly terminal?: boolean | undefined; +} + +export function LaneColumn({ + lane, + tickets, + onOpen, +}: { + readonly lane: LaneColumnView; + readonly tickets: ReadonlyArray; + readonly onOpen: (id: string) => void; +}) { + const { setNodeRef } = useDroppable({ id: `lane:${lane.key}` }); + + return ( +
+
+

{lane.name}

+ + {lane.entry} / {tickets.length} + +
+ ticket.ticketId)} + strategy={verticalListSortingStrategy} + > +
+ {tickets.map((ticket) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/board/TicketCard.tsx b/apps/web/src/components/board/TicketCard.tsx new file mode 100644 index 00000000000..dd7d1a84d51 --- /dev/null +++ b/apps/web/src/components/board/TicketCard.tsx @@ -0,0 +1,106 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cva } from "class-variance-authority"; +import { + CircleAlertIcon, + CircleCheckIcon, + LoaderCircleIcon, + PauseCircleIcon, + type LucideIcon, +} from "lucide-react"; +import type { CSSProperties } from "react"; + +import { Badge } from "~/components/ui/badge"; +import { cn } from "~/lib/utils"; + +export interface TicketCardView { + readonly ticketId: string; + readonly title: string; + readonly status: string; +} + +interface TicketStatusBadge { + readonly label: string; + readonly variant: "error" | "info" | "success" | "warning"; + readonly Icon: LucideIcon; +} + +const ticketCardVariants = cva( + "group w-full cursor-grab rounded-md border border-border/80 bg-card px-3 py-2 text-left text-sm text-card-foreground shadow-xs transition-colors hover:border-border hover:bg-accent/36 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-default", + { + variants: { + dragging: { + false: "", + true: "opacity-50 shadow-md", + }, + }, + defaultVariants: { + dragging: false, + }, + }, +); + +const statusBadgeByStatus: Record = { + waiting_on_user: { + label: "waiting on you", + variant: "warning", + Icon: PauseCircleIcon, + }, + running: { + label: "running", + variant: "info", + Icon: LoaderCircleIcon, + }, + blocked: { + label: "blocked", + variant: "error", + Icon: CircleAlertIcon, + }, + failed: { + label: "blocked", + variant: "error", + Icon: CircleAlertIcon, + }, + done: { + label: "done", + variant: "success", + Icon: CircleCheckIcon, + }, +}; + +export function TicketCard({ + ticket, + onOpen, +}: { + readonly ticket: TicketCardView; + readonly onOpen: (id: string) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: ticket.ticketId, + }); + const badge = statusBadgeByStatus[ticket.status] ?? null; + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index cafba0f829f..08450ce2326 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne import { Route as SettingsCloudRouteImport } from './routes/settings.cloud' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' +import { Route as ChatEnvironmentIdBoardRouteImport } from './routes/_chat.$environmentId.board' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' const SettingsRoute = SettingsRouteImport.update({ @@ -88,6 +89,11 @@ const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ path: '/draft/$draftId', getParentRoute: () => ChatRoute, } as any) +const ChatEnvironmentIdBoardRoute = ChatEnvironmentIdBoardRouteImport.update({ + id: '/$environmentId/board', + path: '/$environmentId/board', + getParentRoute: () => ChatRoute, +} as any) const ChatEnvironmentIdThreadIdRoute = ChatEnvironmentIdThreadIdRouteImport.update({ id: '/$environmentId/$threadId', @@ -108,6 +114,7 @@ export interface FileRoutesByFullPath { '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/$environmentId/board': typeof ChatEnvironmentIdBoardRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { @@ -123,6 +130,7 @@ export interface FileRoutesByTo { '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/$environmentId/board': typeof ChatEnvironmentIdBoardRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesById { @@ -140,6 +148,7 @@ export interface FileRoutesById { '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/_chat/$environmentId/board': typeof ChatEnvironmentIdBoardRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRouteTypes { @@ -157,6 +166,7 @@ export interface FileRouteTypes { | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' + | '/$environmentId/board' | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: @@ -172,6 +182,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/' | '/$environmentId/$threadId' + | '/$environmentId/board' | '/draft/$draftId' id: | '__root__' @@ -188,6 +199,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' + | '/_chat/$environmentId/board' | '/_chat/draft/$draftId' fileRoutesById: FileRoutesById } @@ -290,6 +302,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatDraftDraftIdRouteImport parentRoute: typeof ChatRoute } + '/_chat/$environmentId/board': { + id: '/_chat/$environmentId/board' + path: '/$environmentId/board' + fullPath: '/$environmentId/board' + preLoaderRoute: typeof ChatEnvironmentIdBoardRouteImport + parentRoute: typeof ChatRoute + } '/_chat/$environmentId/$threadId': { id: '/_chat/$environmentId/$threadId' path: '/$environmentId/$threadId' @@ -303,12 +322,14 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute + ChatEnvironmentIdBoardRoute: typeof ChatEnvironmentIdBoardRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, + ChatEnvironmentIdBoardRoute: ChatEnvironmentIdBoardRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, } diff --git a/apps/web/src/routes/_chat.$environmentId.board.tsx b/apps/web/src/routes/_chat.$environmentId.board.tsx new file mode 100644 index 00000000000..a69a4a84b63 --- /dev/null +++ b/apps/web/src/routes/_chat.$environmentId.board.tsx @@ -0,0 +1,82 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BoardId, EnvironmentId, LaneKey, TicketId } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { BoardView } from "../components/board/BoardView"; +import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { readEnvironmentApi } from "../environmentApi"; +import { useStore } from "../store"; +import { emptyBoardState } from "../workflow/boardState"; +import { moveTicket, subscribeBoard } from "../workflow/boardRpc"; + +export interface BoardRouteSearch { + readonly boardId?: string | undefined; +} + +const parseBoardRouteSearch = (search: Record): BoardRouteSearch => { + const boardId = typeof search.boardId === "string" ? search.boardId.trim() : ""; + return boardId ? { boardId } : {}; +}; + +function WorkflowBoardRouteView() { + const { environmentId: rawEnvironmentId } = Route.useParams(); + const { boardId: rawBoardId } = Route.useSearch(); + const [, openDrawer] = useState(null); + const environmentId = useMemo(() => EnvironmentId.make(rawEnvironmentId), [rawEnvironmentId]); + const boardId = useMemo(() => (rawBoardId ? BoardId.make(rawBoardId) : null), [rawBoardId]); + const state = useStore((store) => + boardId ? (store.boardStateById[boardId] ?? emptyBoardState) : emptyBoardState, + ); + + useEffect(() => { + if (!boardId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + return subscribeBoard(api, boardId); + }, [boardId, environmentId]); + + const handleMove = useCallback( + (ticketId: string, toLane: string) => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + void moveTicket(api, TicketId.make(ticketId), LaneKey.make(toLane)); + }, + [environmentId], + ); + + return ( + +
+
+ +
+

+ {state.boardName || "Workflow Board"} +

+
+
+ {boardId ? ( + + ) : ( +
+ No board selected. +
+ )} +
+
+ ); +} + +export const Route = createFileRoute("/_chat/$environmentId/board")({ + validateSearch: parseBoardRouteSearch, + component: WorkflowBoardRouteView, +}); From b2ae9f12347b8d3a120815c9556a582d0add7cb2 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:55:15 -0400 Subject: [PATCH 049/295] feat(web): ticket drill-in with timeline, question inbox, and diff Constraint: Task 7 needed drill-in UI on the current workflow detail contract without inventing missing step thread fields.\nRejected: Building a new diff renderer | The app already has getRenderablePatch and FileDiff wiring for checkpoint diffs.\nConfidence: high\nScope-risk: moderate\nDirective: Extend WorkflowTicketDetailView before adding live per-step thread activity; do not infer thread IDs in the UI.\nTested: pnpm --dir apps/web exec vp test run src/components/board/TicketDrawer.test.tsx; pnpm --dir apps/web exec vp test run src/components/board; pnpm --filter @t3tools/web typecheck; pnpm exec vp check\nNot-tested: Live provider question/approval resume and diff refresh against a running board. --- apps/web/src/components/board/TicketDiff.tsx | 138 ++++++++++++++++ .../components/board/TicketDrawer.test.tsx | 93 +++++++++++ .../web/src/components/board/TicketDrawer.tsx | 150 +++++++++++++++++ .../src/routes/_chat.$environmentId.board.tsx | 152 +++++++++++++++--- 4 files changed, 514 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/board/TicketDiff.tsx create mode 100644 apps/web/src/components/board/TicketDrawer.test.tsx create mode 100644 apps/web/src/components/board/TicketDrawer.tsx diff --git a/apps/web/src/components/board/TicketDiff.tsx b/apps/web/src/components/board/TicketDiff.tsx new file mode 100644 index 00000000000..74719541b96 --- /dev/null +++ b/apps/web/src/components/board/TicketDiff.tsx @@ -0,0 +1,138 @@ +import { FileDiff } from "@pierre/diffs/react"; +import type { EnvironmentApi, TicketDiff as TicketDiffData, TicketId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; +import { + buildFileDiffRenderKey, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { useTheme } from "~/hooks/useTheme"; +import { getTicketDiff } from "~/workflow/boardRpc"; + +type TicketDiffLoadState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly diff: TicketDiffData } + | { readonly status: "error"; readonly message: string }; + +export function TicketDiff({ + api, + ticketId, +}: { + readonly api: EnvironmentApi; + readonly ticketId: TicketId; +}) { + const { resolvedTheme } = useTheme(); + const [loadState, setLoadState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setLoadState({ status: "loading" }); + + void getTicketDiff(api, ticketId).then( + (diff) => { + if (!cancelled) { + setLoadState({ status: "loaded", diff }); + } + }, + (error: unknown) => { + if (!cancelled) { + setLoadState({ status: "error", message: errorMessage(error) }); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [api, ticketId]); + + if (loadState.status === "loading") { + return ( +
+ Loading diff... +
+ ); + } + + if (loadState.status === "error") { + return ( +
+ {loadState.message} +
+ ); + } + + return ; +} + +export function TicketDiffContent({ + diff, + resolvedTheme, +}: { + readonly diff: TicketDiffData; + readonly resolvedTheme: "light" | "dark"; +}) { + const renderablePatch = useMemo( + () => getRenderablePatch(diff.patch, `workflow-ticket:${diff.ticketId}:${resolvedTheme}`), + [diff.patch, diff.ticketId, resolvedTheme], + ); + + return ( +
+
+

Accumulated diff

+

Base {diff.baseRef}

+
+ {diff.files.length > 0 ? ( +
    + {diff.files.map((file) => ( +
  • + + {file.path} + + + + +
  • + ))} +
+ ) : ( +

No changed files.

+ )} + {diff.truncated ?

Patch truncated.

: null} + {!renderablePatch ? ( +

No patch available.

+ ) : renderablePatch.kind === "raw" ? ( +
+          {renderablePatch.text}
+        
+ ) : ( +
+ {renderablePatch.files.map((fileDiff) => ( +
+ + {resolveFileDiffPath(fileDiff)} +
+ ))} +
+ )} +
+ ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unable to load ticket diff."; +} diff --git a/apps/web/src/components/board/TicketDrawer.test.tsx b/apps/web/src/components/board/TicketDrawer.test.tsx new file mode 100644 index 00000000000..8b776fd6846 --- /dev/null +++ b/apps/web/src/components/board/TicketDrawer.test.tsx @@ -0,0 +1,93 @@ +import { TicketId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { TicketDiffContent } from "./TicketDiff"; +import { TicketDrawer } from "./TicketDrawer"; + +vi.mock("@pierre/diffs/react", () => { + const FileDiff = (props: { + fileDiff: { name?: string | null; prevName?: string | null }; + renderHeaderPrefix?: () => ReactNode; + }) => ( +
+ {props.renderHeaderPrefix?.()} + {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"} +
+ ); + + return { FileDiff }; +}); + +const ticketDetail = { + ticket: { + ticketId: "ticket-1", + boardId: "board-1", + title: "Review release blockers", + currentLaneKey: "review", + status: "waiting_on_user", + }, + steps: [ + { + stepRunId: "step-1", + stepKey: "agent-review", + stepType: "agent", + status: "awaiting_user", + waitingReason: "Approve the proposed fix", + }, + { + stepRunId: "step-2", + stepKey: "ship", + stepType: "approval", + status: "pending", + waitingReason: null, + }, + ], +} as const; + +describe("TicketDrawer", () => { + it("renders the step timeline and approval inbox for awaiting-user steps", () => { + const markup = renderToStaticMarkup( + {}} onRunLane={() => {}} />, + ); + + expect(markup).toContain("Review release blockers"); + expect(markup).toContain("agent-review"); + expect(markup).toContain("awaiting user"); + expect(markup).toContain("Approve the proposed fix"); + expect(markup).toContain("Approve"); + expect(markup).toContain("Reject"); + expect(markup).toContain("Run lane"); + }); +}); + +describe("TicketDiffContent", () => { + it("renders file summaries and the parsed patch viewer", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("refs/workflow/tickets/ticket-1/base"); + expect(markup).toContain("src/workflow.ts"); + expect(markup).toContain("+4"); + expect(markup).toContain("-1"); + expect(markup).toContain("file-diff"); + }); +}); diff --git a/apps/web/src/components/board/TicketDrawer.tsx b/apps/web/src/components/board/TicketDrawer.tsx new file mode 100644 index 00000000000..6acbdc02046 --- /dev/null +++ b/apps/web/src/components/board/TicketDrawer.tsx @@ -0,0 +1,150 @@ +import { TicketId, type EnvironmentApi } from "@t3tools/contracts"; +import { CheckIcon, PlayIcon, XIcon } from "lucide-react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +import { TicketDiff } from "./TicketDiff"; + +export interface TicketDrawerDetail { + readonly ticket: { + readonly ticketId: string; + readonly title: string; + readonly currentLaneKey: string; + readonly status: string; + }; + readonly steps: ReadonlyArray<{ + readonly stepRunId: string; + readonly stepKey: string; + readonly stepType: string; + readonly status: string; + readonly waitingReason: string | null; + }>; +} + +export interface TicketDrawerLane { + readonly key: string; + readonly name: string; +} + +export function TicketDrawer({ + api, + detail, + lanes = [], + onApprove, + onMove, + onRunLane, +}: { + readonly api?: EnvironmentApi | undefined; + readonly detail: TicketDrawerDetail; + readonly lanes?: ReadonlyArray; + readonly onApprove: (stepRunId: string, approved: boolean) => void; + readonly onMove?: ((toLane: string) => void) | undefined; + readonly onRunLane: () => void; +}) { + const waitingStepCount = detail.steps.filter((step) => step.status === "awaiting_user").length; + + return ( + + ); +} + +function formatStatusLabel(status: string): string { + return status.replaceAll("_", " "); +} diff --git a/apps/web/src/routes/_chat.$environmentId.board.tsx b/apps/web/src/routes/_chat.$environmentId.board.tsx index a69a4a84b63..2c6c56f2a50 100644 --- a/apps/web/src/routes/_chat.$environmentId.board.tsx +++ b/apps/web/src/routes/_chat.$environmentId.board.tsx @@ -1,13 +1,22 @@ import { createFileRoute } from "@tanstack/react-router"; -import { BoardId, EnvironmentId, LaneKey, TicketId } from "@t3tools/contracts"; +import { + BoardId, + EnvironmentId, + LaneKey, + StepRunId, + TicketId, + type WorkflowTicketDetailView, +} from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState } from "react"; import { BoardView } from "../components/board/BoardView"; +import { TicketDrawer } from "../components/board/TicketDrawer"; +import { RightPanelSheet } from "../components/RightPanelSheet"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; import { readEnvironmentApi } from "../environmentApi"; import { useStore } from "../store"; import { emptyBoardState } from "../workflow/boardState"; -import { moveTicket, subscribeBoard } from "../workflow/boardRpc"; +import { moveTicket, resolveApproval, subscribeBoard } from "../workflow/boardRpc"; export interface BoardRouteSearch { readonly boardId?: string | undefined; @@ -21,7 +30,10 @@ const parseBoardRouteSearch = (search: Record): BoardRouteSearc function WorkflowBoardRouteView() { const { environmentId: rawEnvironmentId } = Route.useParams(); const { boardId: rawBoardId } = Route.useSearch(); - const [, openDrawer] = useState(null); + const [selectedTicketId, setSelectedTicketId] = useState(null); + const [ticketDetail, setTicketDetail] = useState(null); + const [ticketDetailError, setTicketDetailError] = useState(null); + const [ticketDetailReloadKey, setTicketDetailReloadKey] = useState(0); const environmentId = useMemo(() => EnvironmentId.make(rawEnvironmentId), [rawEnvironmentId]); const boardId = useMemo(() => (rawBoardId ? BoardId.make(rawBoardId) : null), [rawBoardId]); const state = useStore((store) => @@ -41,6 +53,42 @@ function WorkflowBoardRouteView() { return subscribeBoard(api, boardId); }, [boardId, environmentId]); + useEffect(() => { + if (!selectedTicketId) { + setTicketDetail(null); + setTicketDetailError(null); + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + setTicketDetail(null); + setTicketDetailError("Environment API unavailable."); + return; + } + + let cancelled = false; + setTicketDetail(null); + setTicketDetailError(null); + + void api.workflow.getTicketDetail({ ticketId: selectedTicketId }).then( + (detail) => { + if (!cancelled) { + setTicketDetail(detail); + } + }, + (error: unknown) => { + if (!cancelled) { + setTicketDetailError(errorMessage(error)); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [environmentId, selectedTicketId, ticketDetailReloadKey]); + const handleMove = useCallback( (ticketId: string, toLane: string) => { const api = readEnvironmentApi(environmentId); @@ -52,30 +100,96 @@ function WorkflowBoardRouteView() { }, [environmentId], ); + const handleOpenTicket = useCallback((ticketId: string) => { + setSelectedTicketId(TicketId.make(ticketId)); + }, []); + const closeTicketDrawer = useCallback(() => { + setSelectedTicketId(null); + }, []); + const reloadTicketDetail = useCallback(() => { + setTicketDetailReloadKey((key) => key + 1); + }, []); + const handleApprove = useCallback( + (stepRunId: string, approved: boolean) => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + void resolveApproval(api, StepRunId.make(stepRunId), approved).then(reloadTicketDetail); + }, + [environmentId, reloadTicketDetail], + ); + const handleRunLane = useCallback(() => { + if (!selectedTicketId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + void api.workflow.runLane({ ticketId: selectedTicketId }).then(reloadTicketDetail); + }, [environmentId, reloadTicketDetail, selectedTicketId]); + const handleDrawerMove = useCallback( + (toLane: string) => { + if (!selectedTicketId) { + return; + } + + handleMove(selectedTicketId, toLane); + reloadTicketDetail(); + }, + [handleMove, reloadTicketDetail, selectedTicketId], + ); + const drawerApi = readEnvironmentApi(environmentId); return ( - -
-
- -
-

- {state.boardName || "Workflow Board"} -

-
-
- {boardId ? ( - + <> + +
+
+ +
+

+ {state.boardName || "Workflow Board"} +

+
+
+ {boardId ? ( + + ) : ( +
+ No board selected. +
+ )} +
+
+ + {ticketDetail ? ( + ) : ( -
- No board selected. +
+ {ticketDetailError ?? "Loading ticket..."}
)} -
- +
+ ); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unable to load ticket detail."; +} + export const Route = createFileRoute("/_chat/$environmentId/board")({ validateSearch: parseBoardRouteSearch, component: WorkflowBoardRouteView, From 86174365aca665b4993d4f139442c0dfd5ade665 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 03:59:07 -0400 Subject: [PATCH 050/295] feat(web): new-ticket affordance + sample board file + e2e smoke Constraint: Task 8 required a board registration path and sample board while staying within v1 scope.\nRejected: Server startup board discovery | The plan allowed a register-board action, and that avoids speculative project discovery at boot.\nConfidence: high\nScope-risk: moderate\nDirective: Keep the sample board lintable on a default install; update the decode/lint test when provider naming changes.\nTested: pnpm --dir apps/web exec vp test run src/components/board; pnpm --dir apps/server exec vp test run src/workflow/sampleBoardFile.test.ts; pnpm --filter @t3tools/web typecheck; pnpm --filter t3 typecheck; pnpm exec vp check\nNot-tested: Full live board run-through with a real provider and screenshot. --- .t3/boards/delivery.json | 59 ++++++++++ .../src/workflow/sampleBoardFile.test.ts | 33 ++++++ .../board/BoardHeaderControls.test.tsx | 33 ++++++ .../components/board/BoardHeaderControls.tsx | 104 ++++++++++++++++++ .../src/routes/_chat.$environmentId.board.tsx | 55 ++++++++- 5 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 .t3/boards/delivery.json create mode 100644 apps/server/src/workflow/sampleBoardFile.test.ts create mode 100644 apps/web/src/components/board/BoardHeaderControls.test.tsx create mode 100644 apps/web/src/components/board/BoardHeaderControls.tsx diff --git a/.t3/boards/delivery.json b/.t3/boards/delivery.json new file mode 100644 index 00000000000..0aff71342e5 --- /dev/null +++ b/.t3/boards/delivery.json @@ -0,0 +1,59 @@ +{ + "name": "Standard delivery", + "settings": { + "maxConcurrentTickets": 3 + }, + "lanes": [ + { + "key": "backlog", + "name": "Backlog", + "entry": "manual" + }, + { + "key": "implement", + "name": "Implement", + "entry": "auto", + "pipeline": [ + { + "key": "code", + "type": "agent", + "agent": { + "instance": "codex", + "model": "gpt-5.4" + }, + "instruction": "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence." + }, + { + "key": "review", + "type": "agent", + "agent": { + "instance": "codex", + "model": "gpt-5.4" + }, + "instruction": "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship." + } + ], + "on": { + "success": "owner_review", + "failure": "needs_attention", + "blocked": "needs_attention" + } + }, + { + "key": "owner_review", + "name": "Owner Review", + "entry": "manual" + }, + { + "key": "needs_attention", + "name": "Needs Attention", + "entry": "manual" + }, + { + "key": "done", + "name": "Done", + "entry": "manual", + "terminal": true + } + ] +} diff --git a/apps/server/src/workflow/sampleBoardFile.test.ts b/apps/server/src/workflow/sampleBoardFile.test.ts new file mode 100644 index 00000000000..e67a28f2fde --- /dev/null +++ b/apps/server/src/workflow/sampleBoardFile.test.ts @@ -0,0 +1,33 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +it.layer(NodeServices.layer)("sample delivery board", (it) => { + it.effect("decodes and lints for the default codex provider", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = path.join(process.cwd(), "../.."); + const raw = yield* fileSystem.readFileString(path.join(repoRoot, ".t3/boards/delivery.json")); + const definition = yield* decodeWorkflowDefinitionJson(raw); + const lintErrors = lintWorkflowDefinition(definition, { + providerInstanceExists: (instanceId) => instanceId === "codex", + instructionFileExists: () => true, + }); + + assert.equal(definition.name, "Standard delivery"); + assert.deepEqual( + lintErrors.map((error) => error.code), + [], + ); + }), + ); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.test.tsx b/apps/web/src/components/board/BoardHeaderControls.test.tsx new file mode 100644 index 00000000000..e2ae91c9833 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.test.tsx @@ -0,0 +1,33 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { BoardHeaderControls, getDefaultInitialLane } from "./BoardHeaderControls"; + +const lanes = [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "implement", name: "Implement", entry: "auto" }, +] as const; + +describe("BoardHeaderControls", () => { + it("defaults new tickets to the first board lane", () => { + expect(getDefaultInitialLane(lanes)).toBe("backlog"); + expect(getDefaultInitialLane([])).toBeNull(); + }); + + it("renders register and new-ticket controls", () => { + const markup = renderToStaticMarkup( + {}} + onRegisterBoard={() => {}} + />, + ); + + expect(markup).toContain("Register board"); + expect(markup).toContain("New ticket"); + expect(markup).toContain("Backlog"); + expect(markup).toContain("Implement"); + }); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.tsx b/apps/web/src/components/board/BoardHeaderControls.tsx new file mode 100644 index 00000000000..19db22876d4 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.tsx @@ -0,0 +1,104 @@ +import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; + +export interface BoardHeaderLane { + readonly key: string; + readonly name: string; +} + +export interface BoardHeaderProject { + readonly id: string; + readonly cwd: string; + readonly name: string; +} + +export interface NewTicketInput { + readonly title: string; + readonly initialLane: string; +} + +export const getDefaultInitialLane = (lanes: ReadonlyArray): string | null => + lanes[0]?.key ?? null; + +export function BoardHeaderControls({ + boardId, + lanes, + project, + onCreateTicket, + onRegisterBoard, +}: { + readonly boardId: string | null; + readonly lanes: ReadonlyArray; + readonly project?: BoardHeaderProject | undefined; + readonly onCreateTicket: (input: NewTicketInput) => void; + readonly onRegisterBoard: () => void; +}) { + const [title, setTitle] = useState(""); + const [initialLane, setInitialLane] = useState(() => getDefaultInitialLane(lanes) ?? ""); + + useEffect(() => { + if (lanes.some((lane) => lane.key === initialLane)) { + return; + } + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + }, [initialLane, lanes]); + + const trimmedTitle = title.trim(); + const canCreateTicket = Boolean(boardId && initialLane && trimmedTitle); + const canRegisterBoard = Boolean(boardId && project); + + return ( +
+ +
{ + event.preventDefault(); + if (!canCreateTicket) { + return; + } + + onCreateTicket({ title: trimmedTitle, initialLane }); + setTitle(""); + }} + > + setTitle(event.currentTarget.value)} + aria-label="New ticket title" + /> + + +
+
+ ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.board.tsx b/apps/web/src/routes/_chat.$environmentId.board.tsx index 2c6c56f2a50..83181ca3edf 100644 --- a/apps/web/src/routes/_chat.$environmentId.board.tsx +++ b/apps/web/src/routes/_chat.$environmentId.board.tsx @@ -9,14 +9,15 @@ import { } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { BoardHeaderControls } from "../components/board/BoardHeaderControls"; import { BoardView } from "../components/board/BoardView"; import { TicketDrawer } from "../components/board/TicketDrawer"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; import { readEnvironmentApi } from "../environmentApi"; -import { useStore } from "../store"; +import { selectProjectsForEnvironment, useStore } from "../store"; import { emptyBoardState } from "../workflow/boardState"; -import { moveTicket, resolveApproval, subscribeBoard } from "../workflow/boardRpc"; +import { createTicket, moveTicket, resolveApproval, subscribeBoard } from "../workflow/boardRpc"; export interface BoardRouteSearch { readonly boardId?: string | undefined; @@ -39,6 +40,8 @@ function WorkflowBoardRouteView() { const state = useStore((store) => boardId ? (store.boardStateById[boardId] ?? emptyBoardState) : emptyBoardState, ); + const projects = useStore((store) => selectProjectsForEnvironment(store, environmentId)); + const registrationProject = projects[0]; useEffect(() => { if (!boardId) { @@ -143,6 +146,47 @@ function WorkflowBoardRouteView() { }, [handleMove, reloadTicketDetail, selectedTicketId], ); + const handleRegisterBoard = useCallback(() => { + if (!boardId || !registrationProject) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + void api.workflow + .registerBoardFromFile({ + boardId, + projectId: registrationProject.id, + filePath: ".t3/boards/delivery.json", + repoRoot: registrationProject.cwd, + }) + .then(() => api.workflow.getBoard({ boardId })) + .then((snapshot) => { + useStore.getState().applyBoardStreamItem(boardId, { kind: "snapshot", snapshot }); + }); + }, [boardId, environmentId, registrationProject]); + const handleCreateTicket = useCallback( + (input: { readonly title: string; readonly initialLane: string }) => { + if (!boardId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + + void createTicket(api, { + boardId, + title: input.title, + initialLane: LaneKey.make(input.initialLane), + }); + }, + [boardId, environmentId], + ); const drawerApi = readEnvironmentApi(environmentId); return ( @@ -156,6 +200,13 @@ function WorkflowBoardRouteView() { {state.boardName || "Workflow Board"}
+ {boardId ? ( From 28356ea585b6478f105fbd610fda3e3950d652f0 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 04:08:22 -0400 Subject: [PATCH 051/295] Allow workflow-capable clients to receive workflow-scoped environment tokens M5 verification showed the token endpoint still parsed the pre-workflow scope whitelist while clients and pairing defaults had moved to the exported contract scope lists. This keeps token exchange and tests coupled to the contract rather than stale literals. Constraint: Workflow scopes are now part of AuthEnvironmentScope and must be requestable through OAuth token exchange. Rejected: Leaving tests on hand-written scope arrays | They hid drift when workflow scopes became standard/admin contract members. Confidence: high Scope-risk: narrow Directive: Prefer AuthStandardClientScopes/AuthAdministrativeScopes in auth tests instead of duplicated scope literals. Tested: pnpm --dir apps/server exec vp test run src/bin.test.ts src/auth/EnvironmentAuth.test.ts src/auth/EnvironmentAuthAdmin.test.ts src/auth/PairingGrantStore.test.ts src/auth/SessionStore.test.ts src/server.test.ts; pnpm --dir apps/web exec vp test run src/localApi.test.ts; pnpm exec vp run typecheck; pnpm exec vp check; pnpm --filter @t3tools/contracts test; pnpm --filter @t3tools/web test; pnpm --filter t3 test; pnpm --dir apps/server exec vp test run src/server.test.ts Not-tested: none --- apps/server/src/auth/EnvironmentAuth.test.ts | 21 +++------------- .../src/auth/EnvironmentAuthAdmin.test.ts | 23 +++--------------- .../server/src/auth/PairingGrantStore.test.ts | 20 +++------------- apps/server/src/auth/SessionStore.test.ts | 9 ++----- apps/server/src/auth/http.ts | 4 ++++ apps/server/src/bin.test.ts | 24 +++---------------- apps/server/src/server.test.ts | 22 ++++------------- apps/web/src/localApi.test.ts | 11 +++++++++ 8 files changed, 34 insertions(+), 100 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 871ec1eab60..14f9fe99dcb 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -92,13 +92,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual([...AuthStandardClientScopes]); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -173,16 +167,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { makeCookieRequest(exchanged.sessionToken), ); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual([...AuthAdministrativeScopes]); expect(verified.subject).toBe("administrative-bootstrap"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 44c28dea416..020b0b9e7d1 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -74,29 +75,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const listedAfterRevoke = yield* environmentAuth.listSessions(); expect(issued.method).toBe("bearer-access-token"); - expect(issued.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(issued.scopes).toEqual([...AuthAdministrativeScopes]); expect(issued.client.deviceType).toBe("bot"); expect(issued.client.label).toBe("deploy-bot"); expect(verified.sessionId).toBe(issued.sessionId); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual([...AuthAdministrativeScopes]); expect(verified.method).toBe("bearer-access-token"); expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 3861b4fc78f..a2685bfeb18 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -52,13 +53,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(first.scopes).toEqual([...AuthStandardClientScopes]); expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); @@ -122,16 +117,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(first.scopes).toEqual([...AuthAdministrativeScopes]); expect(first.subject).toBe("desktop-bootstrap"); expect(second._tag).toBe("BootstrapCredentialInvalidError"); }).pipe( diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 00abd6b9945..33b53d161b8 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -123,13 +124,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.method).toBe("bearer-access-token"); expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual([...AuthStandardClientScopes]); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..ae0ff3fac76 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -4,6 +4,8 @@ import { AuthStandardClientScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, @@ -249,6 +251,8 @@ export const authHttpApiLayer = HttpApiBuilder.group( allowedScopes: new Set([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5911bfd9874..66e4060723b 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -349,28 +349,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof issued.sessionId, "string"); assert.equal(typeof issued.token, "string"); - assert.deepEqual(issued.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(issued.scopes, [...AuthAdministrativeScopes]); assert.equal(listed.length, 1); assert.equal(listed[0]?.sessionId, issued.sessionId); - assert.deepEqual(listed[0]?.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(listed[0]?.scopes, [...AuthAdministrativeScopes]); assert.equal("token" in (listed[0] ?? {}), false); }), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ed21fa09b89..0d747ba03f4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto"; import { AuthAccessTokenType, + AuthAdministrativeScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, @@ -65,6 +66,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const administrativeScopeText = AuthAdministrativeScopes.join(" "); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; @@ -946,9 +948,7 @@ const exchangeAccessToken = ( subject_token: credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + scope: options?.scope ?? administrativeScopeText, ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), ...(options?.clientMetadata?.deviceType ? { client_device_type: options.clientMetadata.deviceType } @@ -1445,10 +1445,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); - assert.equal( - tokenBody.scope, - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ); + assert.equal(tokenBody.scope, administrativeScopeText); assert.equal(typeof tokenBody.access_token, "string"); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -1466,16 +1463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - assert.deepEqual(sessionBody.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(sessionBody.scopes, [...AuthAdministrativeScopes]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index e50dbd9f5f8..52b041e7d30 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -90,6 +90,17 @@ const rpcClientMock = { review: { getDiffPreview: vi.fn(), }, + workflow: { + registerBoardFromFile: vi.fn(), + getBoard: vi.fn(), + subscribeBoard: vi.fn(() => () => undefined), + createTicket: vi.fn(), + moveTicket: vi.fn(), + runLane: vi.fn(), + resolveApproval: vi.fn(), + getTicketDetail: vi.fn(), + getTicketDiff: vi.fn(), + }, server: { getConfig: vi.fn(), refreshProviders: vi.fn(), From 17e47084fc2136cccbde01015f390bfaa0805a95 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 12:55:00 -0400 Subject: [PATCH 052/295] Make workflow boards real-path execution durable Real workflow execution was green only through stubs; the live path needed durable recovery, provider-question waits, repo-root worktrees, and hard supersede handling to satisfy the v1 invariants. Constraint: Fixes were driven by docs/superpowers/reviews/2026-06-07-workflow-boards-v1-adversarial-review.md and the v1 design invariants. Rejected: Stub-only fixes | they would preserve the broken end-to-end runtime path. Confidence: high Scope-risk: broad Directive: Keep future workflow changes covered by real-path tests with temp git repos and provider waits, not only stubbed unit tests. Tested: pnpm exec vp run typecheck; pnpm exec vp check; cd apps/server && pnpm exec vp test run src/workflow; pnpm --filter @t3tools/contracts test Not-tested: full non-workflow server/web suites beyond the required workflow and contracts gates --- .../src/workflow/Layers/ApprovalGate.ts | 39 +- .../Layers/DurableApprovalResume.test.ts | 4 +- .../workflow/Layers/DurableApprovalResume.ts | 54 +- .../workflow/Layers/ProviderDispatchOutbox.ts | 76 +- .../workflow/Layers/RealStepExecutor.test.ts | 82 +- .../src/workflow/Layers/RealStepExecutor.ts | 130 ++- .../TicketCheckpointService.migration.test.ts | 6 +- .../workflow/Layers/TurnStateReader.test.ts | 11 +- .../src/workflow/Layers/TurnStateReader.ts | 40 +- .../Layers/WorkflowEngine.integration.test.ts | 82 +- .../src/workflow/Layers/WorkflowEngine.ts | 352 ++++++- .../src/workflow/Layers/WorkflowRecovery.ts | 89 +- .../WorkflowRuntime.integration.test.ts | 3 + .../Layers/WorkflowRuntime.realpath.test.ts | 915 ++++++++++++++++++ .../src/workflow/Services/ApprovalGate.ts | 2 +- .../Services/ProviderDispatchOutbox.ts | 24 +- .../src/workflow/Services/TurnStateReader.ts | 9 +- .../src/workflow/Services/WorktreePort.ts | 1 + packages/contracts/src/workflow.ts | 8 +- 19 files changed, 1773 insertions(+), 154 deletions(-) create mode 100644 apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts diff --git a/apps/server/src/workflow/Layers/ApprovalGate.ts b/apps/server/src/workflow/Layers/ApprovalGate.ts index 8085f0e240c..830dca377c6 100644 --- a/apps/server/src/workflow/Layers/ApprovalGate.ts +++ b/apps/server/src/workflow/Layers/ApprovalGate.ts @@ -9,6 +9,7 @@ export const ApprovalGateLive = Layer.effect( ApprovalGate, Effect.gen(function* () { const pending = yield* Ref.make(new Map>()); + const activeWaiters = yield* Ref.make(new Map()); const getOrCreate = (stepRunId: string) => Effect.gen(function* () { @@ -22,14 +23,42 @@ export const ApprovalGateLive = Layer.effect( return deferred; }); + const incrementWaiter = (stepRunId: string) => + Ref.update(activeWaiters, (current) => { + const next = new Map(current); + next.set(stepRunId, (next.get(stepRunId) ?? 0) + 1); + return next; + }); + + const decrementWaiter = (stepRunId: string) => + Ref.update(activeWaiters, (current) => { + const next = new Map(current); + const count = (next.get(stepRunId) ?? 0) - 1; + if (count <= 0) { + next.delete(stepRunId); + } else { + next.set(stepRunId, count); + } + return next; + }); + return ApprovalGate.of({ park: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.asVoid), - await: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.flatMap(Deferred.await)), + await: (stepRunId) => + Effect.gen(function* () { + const id = stepRunId as string; + const deferred = yield* getOrCreate(id); + yield* incrementWaiter(id); + return yield* Deferred.await(deferred).pipe(Effect.ensuring(decrementWaiter(id))); + }), resolve: (stepRunId, approved) => - getOrCreate(stepRunId).pipe( - Effect.flatMap((deferred) => Deferred.succeed(deferred, approved)), - Effect.asVoid, - ), + Effect.gen(function* () { + const id = stepRunId as string; + const deferred = yield* getOrCreate(id); + const liveWaiters = (yield* Ref.get(activeWaiters)).get(id) ?? 0; + yield* Deferred.succeed(deferred, approved); + return liveWaiters > 0; + }), }); }), ); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts index f3e30075861..9286f402117 100644 --- a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts @@ -32,7 +32,7 @@ it.effect("parks unresolved workflow approval waits during recovery", () => Layer.provideMerge( Layer.succeed(ApprovalGate, { await: () => Effect.die("unused"), - resolve: () => Effect.void, + resolve: () => Effect.succeed(false), park: (stepRunId) => Ref.update(parked, (ids) => [...ids, stepRunId as string]).pipe(Effect.asVoid), }), @@ -70,7 +70,7 @@ it.effect("routes provider-question approval resolution to the provider response Layer.provideMerge( Layer.succeed(ApprovalGate, { await: () => Effect.die("unused"), - resolve: () => Effect.void, + resolve: () => Effect.succeed(false), park: () => Effect.void, }), ), diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.ts index 5aa7114bc8c..423c0bb00d4 100644 --- a/apps/server/src/workflow/Layers/DurableApprovalResume.ts +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.ts @@ -1,7 +1,5 @@ -import type { WorkflowEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import type { SqlError } from "effect/unstable/sql/SqlError"; @@ -11,9 +9,12 @@ import { type DurableApprovalResumeShape, } from "../Services/DurableApprovalResume.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; -import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; -type PendingWait = Extract; +interface PendingWaitRow { + readonly providerRequestId: string | null; + readonly providerThreadId: string | null; + readonly stepRunId: string; +} const toResumeError = (message: string) => (cause: unknown) => new WorkflowEventStoreError({ message, cause }); @@ -21,25 +22,8 @@ const toResumeError = (message: string) => (cause: unknown) => const wrapSql =
(effect: Effect.Effect) => effect.pipe(Effect.mapError(toResumeError("approval resume sql failed"))); -const pendingWaits = (events: ReadonlyArray) => { - const pending = new Map(); - - for (const event of events) { - if (event.type === "StepAwaitingUser") { - pending.set(event.payload.stepRunId as string, event); - continue; - } - if (event.type === "StepUserResolved") { - pending.delete(event.payload.stepRunId as string); - } - } - - return Array.from(pending.values()); -}; - const make = Effect.gen(function* () { const approvals = yield* ApprovalGate; - const store = yield* WorkflowEventStore; const sql = yield* SqlClient.SqlClient; const resetProviderDispatch = (stepRunId: string) => @@ -54,14 +38,28 @@ const make = Effect.gen(function* () { const resume: DurableApprovalResumeShape["resume"] = () => Effect.gen(function* () { - const events = yield* Stream.runCollect(store.readAll()).pipe( - Effect.map((chunk) => Array.from(chunk)), - ); - for (const event of pendingWaits(events)) { - if (event.payload.providerThreadId && event.payload.providerRequestId) { - yield* resetProviderDispatch(event.payload.stepRunId as string); + const pendingWaits = yield* wrapSql(sql` + SELECT + json_extract(await.payload_json, '$.providerRequestId') AS "providerRequestId", + json_extract(await.payload_json, '$.providerThreadId') AS "providerThreadId", + json_extract(await.payload_json, '$.stepRunId') AS "stepRunId" + FROM workflow_events AS await + WHERE await.event_type = 'StepAwaitingUser' + AND NOT EXISTS ( + SELECT 1 + FROM workflow_events AS resolved + WHERE resolved.event_type = 'StepUserResolved' + AND json_extract(resolved.payload_json, '$.stepRunId') + = json_extract(await.payload_json, '$.stepRunId') + ) + ORDER BY await.sequence ASC + `); + + for (const pending of pendingWaits) { + if (pending.providerThreadId && pending.providerRequestId) { + yield* resetProviderDispatch(pending.stepRunId); } else { - yield* approvals.park(event.payload.stepRunId); + yield* approvals.park(pending.stepRunId as never); } } }); diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts index 315961f0586..65b714702bb 100644 --- a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts @@ -5,8 +5,10 @@ import { type ProviderSessionStartInput, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import type { SqlError } from "effect/unstable/sql/SqlError"; @@ -17,12 +19,14 @@ import { ProviderDispatchOutbox, ProviderTurnPort, type DispatchRequest, + type ProviderDispatchTerminalResult, type ProviderDispatchOutboxShape, type ProviderTurnPortShape, } from "../Services/ProviderDispatchOutbox.ts"; import { TurnStateReader } from "../Services/TurnStateReader.ts"; const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const TERMINAL_WAIT_TIMEOUT = Duration.minutes(30); const toDispatchError = (message: string) => (cause: unknown) => new WorkflowEventStoreError({ message, cause }); @@ -50,6 +54,18 @@ const make = Effect.gen(function* () { WHERE dispatch_id = ${dispatchId} `).pipe(Effect.map((rows) => rows[0]?.status ?? null)); + const confirmStep: ProviderDispatchOutboxShape["confirmStep"] = (stepRunId) => + Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE step_run_id = ${stepRunId} + AND status != 'confirmed' + `); + }); + const ensureStarted: ProviderDispatchOutboxShape["ensureStarted"] = (req) => Effect.gen(function* () { const createdAt = yield* nowIso; @@ -97,25 +113,52 @@ const make = Effect.gen(function* () { `); }); - const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => - Effect.gen(function* () { - let state = yield* turns.read(threadId); - while (state._tag === "running") { - yield* Effect.sleep("500 millis"); - state = yield* turns.read(threadId); - } - const confirmedAt = yield* nowIso; - yield* wrapSql(sql` + const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => { + const waitForTerminal: Effect.Effect = + Effect.gen(function* () { + let state = yield* turns.read(threadId); + while (state._tag === "running") { + yield* Effect.sleep("500 millis"); + state = yield* turns.read(threadId); + } + if (state._tag === "awaiting_user") { + return { + ok: false, + awaitingUser: true, + waitingReason: state.waitingReason, + providerThreadId: state.providerThreadId, + providerRequestId: state.providerRequestId, + providerResponseKind: state.providerResponseKind, + } satisfies ProviderDispatchTerminalResult; + } + + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` UPDATE workflow_dispatch_outbox SET status = 'confirmed', confirmed_at = ${confirmedAt} WHERE dispatch_id = ${dispatchId} `); - return state._tag === "completed" - ? { ok: true } - : { ok: false, error: state._tag === "failed" ? state.error : "unknown" }; - }); + return state._tag === "completed" + ? ({ ok: true } satisfies ProviderDispatchTerminalResult) + : ({ ok: false, error: state.error } satisfies ProviderDispatchTerminalResult); + }); + + return waitForTerminal.pipe( + Effect.timeoutOption(TERMINAL_WAIT_TIMEOUT), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.succeed({ + ok: false, + error: "turn did not reach a terminal state before timeout", + } satisfies ProviderDispatchTerminalResult), + onSome: Effect.succeed, + }), + ), + ); + }; const recoverPending: ProviderDispatchOutboxShape["recoverPending"] = () => Effect.gen(function* () { @@ -141,7 +184,12 @@ const make = Effect.gen(function* () { ); }); - return { ensureStarted, awaitTerminal, recoverPending } satisfies ProviderDispatchOutboxShape; + return { + confirmStep, + ensureStarted, + awaitTerminal, + recoverPending, + } satisfies ProviderDispatchOutboxShape; }); export const ProviderDispatchOutboxLive = Layer.effect(ProviderDispatchOutbox, make); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts index cd4bb3aea2f..412e39bd4d9 100644 --- a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import type { StepExecutionContext } from "../Services/StepExecutor.ts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,10 +7,14 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { MigrationsLive } from "../../persistence/Migrations.ts"; -import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderDispatchOutbox, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; import { SetupRunService } from "../Services/SetupRunService.ts"; import { StepExecutor } from "../Services/StepExecutor.ts"; import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import { WorktreePort } from "../Services/WorktreePort.ts"; import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; @@ -39,13 +44,17 @@ const checkpointCalls: Array = []; const preRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/pre"; const postRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/post"; -const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => +const mk = (terminal: ProviderDispatchTerminalResult) => it.layer( RealStepExecutorLive.pipe( Layer.provideMerge( Layer.succeed(WorktreePort, { ensureWorktree: () => - Effect.succeed({ worktreeRef: "wt-ticket-1", path: "/tmp/wt-ticket-1" }), + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), }), ), Layer.provideMerge(WorktreeLeaseServiceLive), @@ -75,6 +84,7 @@ const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => ), Layer.provideMerge( Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, ensureStarted: () => Effect.void, awaitTerminal: () => Effect.succeed(terminal), recoverPending: () => Effect.void, @@ -85,6 +95,7 @@ const mk = (terminal: { readonly ok: boolean; readonly error?: string }) => Layer.provideMerge(DeterministicWorkflowIds), Layer.provideMerge(MigrationsLive), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), ), ); @@ -176,3 +187,68 @@ mk({ ok: false, error: "provider failed" })("RealStepExecutor failure", (it) => }), ); }); + +const preCheckpointFailureLayer = it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(true), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + kind === "pre" + ? Effect.fail(new WorkflowEventStoreError({ message: "pre checkpoint failed" })) + : Effect.succeed(postRef), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: () => Effect.void, + awaitTerminal: () => Effect.succeed({ ok: true }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ), +); + +preCheckpointFailureLayer("RealStepExecutor pre-dispatch failure", (it) => { + it.effect("releases the worktree lease when pre-step checkpoint capture fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { _tag: "failed", error: "executor error" }); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts index db3464da9c3..e259e71d77e 100644 --- a/apps/server/src/workflow/Layers/RealStepExecutor.ts +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -2,7 +2,11 @@ import { TrimmedNonEmptyString, type StepOutcome } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; @@ -22,6 +26,13 @@ import { const toExecutorError = (message: string) => (cause: unknown) => new WorkflowEventStoreError({ message, cause }); +const wrapSql = (message: string, effect: Effect.Effect) => + effect.pipe(Effect.mapError(toExecutorError(message))); + +interface TicketProjectRow { + readonly repoRoot: string; +} + const make = Effect.gen(function* () { const worktrees = yield* WorktreePort; const lease = yield* WorktreeLeaseService; @@ -30,6 +41,8 @@ const make = Effect.gen(function* () { const ids = yield* WorkflowIds; const ticketCheckpoints = yield* TicketCheckpointService; const committer = yield* WorkflowEventCommitter; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const execute: StepExecutorShape["execute"] = (ctx) => Effect.gen(function* () { @@ -59,7 +72,11 @@ const make = Effect.gen(function* () { const dispatchId = yield* ids.eventId(); const threadId = yield* ids.eventId(); const instruction = - typeof step.instruction === "string" ? step.instruction : `@@file:${step.instruction.file}`; + typeof step.instruction === "string" + ? step.instruction + : yield* fileSystem + .readFileString(path.resolve(worktree.repoRoot, step.instruction.file)) + .pipe(Effect.mapError(toExecutorError("instruction file read failed"))); const releaseIfStillOwner = lease.isValid(worktree.worktreeRef, acquired.fenceToken).pipe( Effect.flatMap((valid) => @@ -87,7 +104,7 @@ const make = Effect.gen(function* () { worktreePath: worktree.path, }); return yield* dispatch.awaitTerminal(dispatchId as never, threadId as never); - }).pipe(Effect.exit, Effect.ensuring(releaseIfStillOwner)); + }).pipe(Effect.exit); const postRef = yield* ticketCheckpoints.captureStep( ctx.ticketId, ctx.stepRunId, @@ -107,14 +124,24 @@ const make = Effect.gen(function* () { return yield* Effect.failCause(terminalExit.cause); } return terminalExit.value; - }); + }).pipe(Effect.ensuring(releaseIfStillOwner)); - return result.ok - ? ({ _tag: "completed" } satisfies StepOutcome) - : ({ - _tag: "failed", - error: result.error ?? "turn failed", - } satisfies StepOutcome); + if (result.ok) { + return { _tag: "completed" } satisfies StepOutcome; + } + if ("awaitingUser" in result) { + return { + _tag: "awaiting_user", + waitingReason: result.waitingReason, + providerThreadId: result.providerThreadId, + providerRequestId: result.providerRequestId, + providerResponseKind: result.providerResponseKind, + } satisfies StepOutcome; + } + return { + _tag: "failed", + error: result.error ?? "turn failed", + } satisfies StepOutcome; }).pipe( Effect.orElseSucceed( () => ({ _tag: "failed", error: "executor error" }) satisfies StepOutcome, @@ -130,24 +157,79 @@ export const WorktreePortLive = Layer.effect( WorktreePort, Effect.gen(function* () { const git = yield* GitWorkflowService; + const sql = yield* SqlClient.SqlClient; + const fileSystem = yield* FileSystem.FileSystem; + + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + + const repoRootForTicket = (ticketId: string) => + wrapSql( + "ticket project lookup failed", + sql` + SELECT projects.workspace_root AS "repoRoot" + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = ${ticketId} + LIMIT 1 + `, + ).pipe( + Effect.flatMap((rows) => { + const repoRoot = rows[0]?.repoRoot; + return repoRoot + ? Effect.succeed(repoRoot) + : Effect.fail( + new WorkflowEventStoreError({ + message: `project repo root not found for ticket ${ticketId}`, + }), + ); + }), + ); const ensureWorktree: WorktreePortShape["ensureWorktree"] = (ticketId) => - git - .createWorktree({ - cwd: TrimmedNonEmptyString.make(process.cwd()), - refName: TrimmedNonEmptyString.make("HEAD"), - newRefName: TrimmedNonEmptyString.make(`workflow/${ticketId}`), - path: null, - }) - .pipe( - Effect.map( - (result): WorktreeHandle => ({ - worktreeRef: result.worktree.refName, - path: result.worktree.path, - }), - ), - Effect.mapError(toExecutorError("worktree creation failed")), + Effect.gen(function* () { + const repoRoot = yield* repoRootForTicket(ticketId as string).pipe( + Effect.flatMap(canonicalizeExistingPath), ); + const worktreeRef = `workflow/${ticketId}`; + const refs = yield* git + .listRefs({ cwd: TrimmedNonEmptyString.make(repoRoot) }) + .pipe(Effect.mapError(toExecutorError("worktree ref lookup failed"))); + const existing = refs.refs.find((ref) => !ref.isRemote && ref.name === worktreeRef); + if (existing?.worktreePath) { + return { + repoRoot, + worktreeRef, + path: yield* canonicalizeExistingPath(existing.worktreePath), + } satisfies WorktreeHandle; + } + + const result = yield* git + .createWorktree( + existing + ? { + cwd: TrimmedNonEmptyString.make(repoRoot), + refName: TrimmedNonEmptyString.make(worktreeRef), + path: null, + } + : { + cwd: TrimmedNonEmptyString.make(repoRoot), + refName: TrimmedNonEmptyString.make("HEAD"), + newRefName: TrimmedNonEmptyString.make(worktreeRef), + path: null, + }, + ) + .pipe(Effect.mapError(toExecutorError("worktree creation failed"))); + + return { + repoRoot, + worktreeRef: result.worktree.refName, + path: yield* canonicalizeExistingPath(result.worktree.path), + } satisfies WorktreeHandle; + }); return { ensureWorktree } satisfies WorktreePortShape; }), diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts index 0bd23e3feca..e7d793a4cfd 100644 --- a/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts @@ -13,9 +13,9 @@ layer("step refs migration", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; - const names = cols.map((column) => column.name); - assert.isTrue(names.includes("pre_checkpoint_ref")); - assert.isTrue(names.includes("post_checkpoint_ref")); + const names = new Set(cols.map((column) => column.name)); + assert.isTrue(names.has("pre_checkpoint_ref")); + assert.isTrue(names.has("post_checkpoint_ref")); }), ); }); diff --git a/apps/server/src/workflow/Layers/TurnStateReader.test.ts b/apps/server/src/workflow/Layers/TurnStateReader.test.ts index 3933773c434..af0db6c6e1c 100644 --- a/apps/server/src/workflow/Layers/TurnStateReader.test.ts +++ b/apps/server/src/workflow/Layers/TurnStateReader.test.ts @@ -2,6 +2,8 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; import { TurnProjectionPort, TurnStateReader } from "../Services/TurnStateReader.ts"; import { TurnStateReaderLive } from "./TurnStateReader.ts"; @@ -11,7 +13,14 @@ const stub = (state: string) => Effect.succeed({ state, completed: state === "completed" || state === "error" }), }); -const mk = (state: string) => it.layer(TurnStateReaderLive.pipe(Layer.provideMerge(stub(state)))); +const mk = (state: string) => + it.layer( + TurnStateReaderLive.pipe( + Layer.provideMerge(stub(state)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); mk("completed")("TurnStateReader completed", (it) => { it.effect("maps completed", () => diff --git a/apps/server/src/workflow/Layers/TurnStateReader.ts b/apps/server/src/workflow/Layers/TurnStateReader.ts index d0a5be90229..9973ba70b0d 100644 --- a/apps/server/src/workflow/Layers/TurnStateReader.ts +++ b/apps/server/src/workflow/Layers/TurnStateReader.ts @@ -1,5 +1,7 @@ +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { @@ -10,6 +12,10 @@ import { type TurnStateReaderShape, } from "../Services/TurnStateReader.ts"; +interface PendingProviderRequestRow { + readonly requestId: string; +} + const toTurnState = (state: string): TurnState => { if (state === "completed") { return { _tag: "completed" }; @@ -22,9 +28,41 @@ const toTurnState = (state: string): TurnState => { const make = Effect.gen(function* () { const port = yield* TurnProjectionPort; + const sql = yield* SqlClient.SqlClient; + + const pendingProviderRequest = (threadId: ThreadId) => + sql` + SELECT request_id AS "requestId" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ORDER BY created_at ASC + LIMIT 1 + `.pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.orElseSucceed(() => null), + ); const read: TurnStateReaderShape["read"] = (threadId) => - port.getLatestTurnState(threadId).pipe(Effect.map(({ state }) => toTurnState(state))); + Effect.gen(function* () { + const { state } = yield* port.getLatestTurnState(threadId); + const turnState = toTurnState(state); + if (turnState._tag !== "running") { + return turnState; + } + + const pending = yield* pendingProviderRequest(threadId); + if (pending) { + return { + _tag: "awaiting_user", + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: ApprovalRequestId.make(pending.requestId), + providerResponseKind: "request", + } satisfies TurnState; + } + return turnState; + }); return { read } satisfies TurnStateReaderShape; }); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index cdbb9316c87..37fb91f2c0e 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -1,11 +1,14 @@ +// @effect-diagnostics globalTimers:off import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; @@ -61,16 +64,31 @@ const awaitStatus = (ticketId: string, status: string) => const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => Effect.gen(function* () { const read = yield* WorkflowReadModel; - for (let attempt = 0; attempt < 20; attempt += 1) { + for (let attempt = 0; attempt < 50; attempt += 1) { const detail = yield* read.getTicketDetail(ticketId as never); if (predicate(detail)) { return detail; } - yield* Effect.sleep("10 millis"); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; } return yield* read.getTicketDetail(ticketId as never); }); +const awaitDeferredWithinYields = (deferred: Deferred.Deferred, label: string) => + Effect.gen(function* () { + const fiber = yield* Effect.forkChild(Deferred.await(deferred)); + for (let attempt = 0; attempt < 50; attempt += 1) { + const exit = yield* Effect.sync(() => fiber.pollUnsafe()); + if (exit !== undefined) { + return yield* Fiber.join(fiber); + } + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(fiber); + assert.fail(`Timed out waiting for ${label}`); + }); + const successLayer = it.layer(baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }))); successLayer("WorkflowEngine integration", (it) => { @@ -123,6 +141,33 @@ failLayer("WorkflowEngine integration failure path", (it) => { ); }); +const explodingExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.fail(new WorkflowEventStoreError({ message: "executor exploded" })) as never, +} satisfies StepExecutorShape); + +const explodingLayer = it.layer(baseLayer(explodingExecutor)); + +explodingLayer("WorkflowEngine pipeline error handling", (it) => { + it.effect("records a failed step and routes when the executor effect fails", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-explodes" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-explodes" as never, + title: "Explode", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(detail?.steps[0]?.status, "failed"); + }), + ); +}); + const approvalDefinition = { name: "approval-wf", lanes: [ @@ -164,14 +209,17 @@ successLayer("WorkflowEngine approval gate", (it) => { }); let supersedeStarted: Deferred.Deferred | undefined; +let supersedeInterrupted: Deferred.Deferred | undefined; let supersedeRelease: Deferred.Deferred | undefined; const blockingSuccessExecutor = Layer.effect( StepExecutor, Effect.gen(function* () { const started = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); const release = yield* Deferred.make(); supersedeStarted = started; + supersedeInterrupted = interrupted; supersedeRelease = release; return StepExecutor.of({ @@ -180,7 +228,9 @@ const blockingSuccessExecutor = Layer.effect( yield* Deferred.succeed(started, undefined); yield* Deferred.await(release); return { _tag: "completed" as const }; - }), + }).pipe( + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined).pipe(Effect.ignore)), + ), } satisfies StepExecutorShape); }), ); @@ -202,7 +252,7 @@ supersedeLayer("WorkflowEngine manual move supersede", (it) => { yield* Effect.yieldNow; assert.exists(supersedeStarted); assert.exists(supersedeRelease); - yield* Deferred.await(supersedeStarted).pipe(Effect.timeout("1 second")); + yield* awaitDeferredWithinYields(supersedeStarted, "supersede start"); yield* engine.moveTicket(ticketId, "needs" as never); yield* Deferred.succeed(supersedeRelease, undefined); @@ -210,4 +260,28 @@ supersedeLayer("WorkflowEngine manual move supersede", (it) => { assert.equal(detail?.ticket.currentLaneKey, "needs"); }), ); + + it.effect("manual move interrupts the stale running pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-hard-supersede" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-hard-supersede" as never, + title: "Interrupt stale work", + initialLane: "impl" as never, + }); + yield* Effect.yieldNow; + assert.exists(supersedeStarted); + assert.exists(supersedeInterrupted); + yield* awaitDeferredWithinYields(supersedeStarted, "hard supersede start"); + + yield* engine.moveTicket(ticketId, "needs" as never); + + yield* awaitDeferredWithinYields(supersedeInterrupted, "hard supersede interrupt"); + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); }); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index 59830addce4..4dea7d32fd7 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -3,6 +3,7 @@ import type { LaneEntryToken, LaneKey, PipelineRunId, + StepOutcome, StepRunId, TicketId, WorkflowEventId, @@ -12,15 +13,19 @@ import type { import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { ApprovalGate } from "../Services/ApprovalGate.ts"; import { BoardRegistry } from "../Services/BoardRegistry.ts"; -import type { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; import { StepExecutor } from "../Services/StepExecutor.ts"; import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; @@ -38,8 +43,25 @@ type StepResult = "completed" | "failed" | "blocked"; type MoveReason = "manual" | "routed" | "initial"; const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const formatError = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const toEngineSqlError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow engine sql failed", cause }); +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toEngineSqlError)); type PendingWait = Extract; +type StepStarted = Extract; +type PipelineStarted = Extract; +type TicketCreated = Extract; + +interface ActivePipeline { + readonly fiber: Fiber.Fiber; + readonly laneEntryToken: LaneEntryToken; +} + +interface StepTicketRow { + readonly ticketId: TicketId; +} const make = Effect.gen(function* () { const approvals = yield* ApprovalGate; @@ -48,7 +70,9 @@ const make = Effect.gen(function* () { const ids = yield* WorkflowIds; const read = yield* WorkflowReadModel; const registry = yield* BoardRegistry; + const sql = yield* SqlClient.SqlClient; const boardSemaphores = yield* SynchronizedRef.make>(new Map()); + const runningPipelines = yield* SynchronizedRef.make>(new Map()); const getOptionalServices = Effect.context().pipe( Effect.map((context) => ({ @@ -56,31 +80,68 @@ const make = Effect.gen(function* () { context as Context.Context, ProviderResponsePort, ), + providerDispatches: Context.getOption( + context as Context.Context, + ProviderDispatchOutbox, + ), store: Context.getOption(context as Context.Context, WorkflowEventStore), })), ); - const pendingWaitFor = (stepRunId: StepRunId) => + const ticketIdForStepRun = (stepRunId: StepRunId) => + wrapSql(sql` + SELECT ticket_id AS "ticketId" + FROM projection_step_run + WHERE step_run_id = ${stepRunId} + UNION ALL + SELECT ticket_id AS "ticketId" + FROM workflow_events + WHERE event_type = 'StepAwaitingUser' + AND json_extract(payload_json, '$.stepRunId') = ${stepRunId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0]?.ticketId ?? null)); + + const readStoredEventsForStep = (stepRunId: StepRunId) => Effect.gen(function* () { const { store } = yield* getOptionalServices; if (Option.isNone(store)) { return null; } - const events = yield* Stream.runCollect(store.value.readAll()).pipe( + const ticketId = yield* ticketIdForStepRun(stepRunId); + if (ticketId === null) { + return null; + } + + return yield* Stream.runCollect(store.value.readByTicket(ticketId)).pipe( Effect.map((chunk) => Array.from(chunk)), ); - let pending: PendingWait | null = null; - for (const event of events) { - if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { - pending = event; - continue; - } - if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { - pending = null; - } + }); + + const pendingWaitInEvents = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => { + let pending: PendingWait | null = null; + for (const event of events) { + if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { + pending = event; + continue; + } + if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { + pending = null; + } + } + return pending; + }; + + const pendingWaitFor = (stepRunId: StepRunId) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(stepRunId); + if (events === null) { + return null; } - return pending; + return pendingWaitInEvents(events, stepRunId); }); const semaphoreFor = (boardId: BoardId, permits: number) => @@ -117,6 +178,37 @@ const make = Effect.gen(function* () { .getTicketDetail(ticketId) .pipe(Effect.map((detail) => detail?.ticket.currentLaneEntryToken ?? null)); + const clearRunningPipeline = (ticketId: TicketId, laneEntryToken: LaneEntryToken) => + SynchronizedRef.update(runningPipelines, (current) => { + const key = ticketId as string; + const active = current.get(key); + if (!active || active.laneEntryToken !== laneEntryToken) { + return current; + } + + const next = new Map(current); + next.delete(key); + return next; + }); + + const interruptRunningPipeline = (ticketId: TicketId) => + Effect.gen(function* () { + const active = yield* SynchronizedRef.modify(runningPipelines, (current) => { + const key = ticketId as string; + const existing = current.get(key) ?? null; + if (!existing) { + return [null, current] as const; + } + + const next = new Map(current); + next.delete(key); + return [existing, next] as const; + }); + if (active) { + yield* Fiber.interrupt(active.fiber).pipe(Effect.ignore); + } + }); + const runStep = ( ticketId: TicketId, boardId: BoardId, @@ -152,22 +244,48 @@ const make = Effect.gen(function* () { return "completed"; } - const outcome = yield* executor.execute({ - ticketId, - boardId, - pipelineRunId, - stepRunId, - laneEntryToken, - step, - }); + const outcome = yield* ( + executor.execute({ + ticketId, + boardId, + pipelineRunId, + stepRunId, + laneEntryToken, + step, + }) as Effect.Effect + ).pipe( + Effect.catch((error) => + Effect.succeed({ _tag: "failed", error: formatError(error) } as const), + ), + ); if (outcome._tag === "awaiting_user") { yield* commit({ type: "StepAwaitingUser", ticketId, - payload: { stepRunId, waitingReason: outcome.waitingReason }, + payload: { + stepRunId, + waitingReason: outcome.waitingReason, + ...(outcome.providerThreadId === undefined + ? {} + : { providerThreadId: outcome.providerThreadId }), + ...(outcome.providerRequestId === undefined + ? {} + : { providerRequestId: outcome.providerRequestId }), + ...(outcome.providerResponseKind === undefined + ? {} + : { providerResponseKind: outcome.providerResponseKind }), + }, }); const approved = yield* approvals.await(stepRunId); yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + const { providerDispatches } = yield* getOptionalServices; + if ( + outcome.providerThreadId !== undefined && + outcome.providerRequestId !== undefined && + Option.isSome(providerDispatches) + ) { + yield* providerDispatches.value.confirmStep(stepRunId); + } if (!approved) { yield* commit({ type: "StepFailed", @@ -205,27 +323,22 @@ const make = Effect.gen(function* () { yield* semaphore.withPermits(1)(runPipelineBody(ticketId, boardId, lane, laneEntryToken)); }).pipe(Effect.catch(() => Effect.void)); - const runPipelineBody = ( + const completePipelineFrom = ( ticketId: TicketId, boardId: BoardId, lane: WorkflowLane, laneEntryToken: LaneEntryToken, + pipelineRunId: PipelineRunId, + steps: ReadonlyArray, + startIndex: number, + initialResult: PipelineResult, ): Effect.Effect => Effect.gen(function* () { - const steps = lane.pipeline ?? []; - if (steps.length === 0) { - return; - } - - const pipelineRunId = yield* ids.pipelineRunId(); - yield* commit({ - type: "PipelineStarted", - ticketId, - payload: { pipelineRunId, laneKey: lane.key, laneEntryToken }, - }); - - let result: PipelineResult = "success"; - for (const step of steps) { + let result: PipelineResult = initialResult; + for (const step of steps.slice(startIndex)) { + if (result !== "success") { + break; + } const stepResult = yield* runStep(ticketId, boardId, pipelineRunId, step, laneEntryToken); if (stepResult === "failed") { result = "failure"; @@ -250,15 +363,54 @@ const make = Effect.gen(function* () { yield* route(ticketId, boardId, lane, result); }); + const runPipelineBody = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect => + Effect.gen(function* () { + const steps = lane.pipeline ?? []; + if (steps.length === 0) { + return; + } + + const pipelineRunId = yield* ids.pipelineRunId(); + yield* commit({ + type: "PipelineStarted", + ticketId, + payload: { pipelineRunId, laneKey: lane.key, laneEntryToken }, + }); + + yield* completePipelineFrom( + ticketId, + boardId, + lane, + laneEntryToken, + pipelineRunId, + steps, + 0, + "success", + ); + }); + const startPipeline = ( ticketId: TicketId, boardId: BoardId, lane: WorkflowLane, laneEntryToken: LaneEntryToken, ) => - runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( - Effect.forkDetach({ startImmediately: true }), - ); + Effect.gen(function* () { + const fiber = yield* runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( + Effect.ensuring(clearRunningPipeline(ticketId, laneEntryToken)), + Effect.forkDetach({ startImmediately: true }), + ); + yield* SynchronizedRef.update(runningPipelines, (current) => { + const next = new Map(current); + next.set(ticketId as string, { fiber, laneEntryToken }); + return next; + }); + }); const route = ( ticketId: TicketId, @@ -289,6 +441,10 @@ const make = Effect.gen(function* () { reason: MoveReason, ): Effect.Effect => Effect.gen(function* () { + if (reason === "manual") { + yield* interruptRunningPipeline(ticketId); + } + const laneEntryToken = yield* ids.token(); yield* commit({ type: "TicketMovedToLane", @@ -350,6 +506,117 @@ const make = Effect.gen(function* () { } }); + const recoveredApprovalContext = ( + events: ReadonlyArray, + pending: PendingWait, + ) => { + let stepStarted: StepStarted | null = null; + let pipelineStarted: PipelineStarted | null = null; + let ticketCreated: TicketCreated | null = null; + + for (const event of events) { + if (event.type === "StepStarted" && event.payload.stepRunId === pending.payload.stepRunId) { + stepStarted = event; + } + if (event.type === "TicketCreated" && event.ticketId === pending.ticketId) { + ticketCreated = event; + } + } + if (!stepStarted) { + return null; + } + + for (const event of events) { + if ( + event.type === "PipelineStarted" && + event.payload.pipelineRunId === stepStarted.payload.pipelineRunId + ) { + pipelineStarted = event; + } + } + if (!pipelineStarted || !ticketCreated) { + return null; + } + + return { stepStarted, pipelineStarted, ticketCreated }; + }; + + const continueRecoveredApproval = (pending: PendingWait, approved: boolean) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(pending.payload.stepRunId); + if (events === null || !pendingWaitInEvents(events, pending.payload.stepRunId)) { + return; + } + + const recovered = recoveredApprovalContext(events, pending); + if (!recovered) { + return; + } + + const boardId = recovered.ticketCreated.payload.boardId; + const laneEntryToken = recovered.pipelineStarted.payload.laneEntryToken; + const lane = yield* registry.getLane(boardId, recovered.pipelineStarted.payload.laneKey); + if (!lane) { + return; + } + + const steps = lane.pipeline ?? []; + const currentStepIndex = steps.findIndex( + (step) => step.key === recovered.stepStarted.payload.stepKey, + ); + if (currentStepIndex < 0) { + return; + } + + yield* commit({ + type: "StepUserResolved", + ticketId: pending.ticketId, + payload: { stepRunId: pending.payload.stepRunId }, + }); + const { providerDispatches } = yield* getOptionalServices; + if ( + pending.payload.providerThreadId && + pending.payload.providerRequestId && + Option.isSome(providerDispatches) + ) { + yield* providerDispatches.value.confirmStep(pending.payload.stepRunId); + } + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId: pending.ticketId, + payload: { stepRunId: pending.payload.stepRunId, error: "rejected" }, + }); + yield* completePipelineFrom( + pending.ticketId, + boardId, + lane, + laneEntryToken, + recovered.pipelineStarted.payload.pipelineRunId, + steps, + steps.length, + "failure", + ); + return; + } + + yield* commit({ + type: "StepCompleted", + ticketId: pending.ticketId, + payload: { stepRunId: pending.payload.stepRunId }, + }); + yield* completePipelineFrom( + pending.ticketId, + boardId, + lane, + laneEntryToken, + recovered.pipelineStarted.payload.pipelineRunId, + steps, + currentStepIndex + 1, + "success", + ); + }); + const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => Effect.gen(function* () { const pending = yield* pendingWaitFor(stepRunId); @@ -368,7 +635,10 @@ const make = Effect.gen(function* () { }); } - yield* approvals.resolve(stepRunId, approved); + const resumedLiveWaiter = yield* approvals.resolve(stepRunId, approved); + if (!resumedLiveWaiter && pending) { + yield* continueRecoveredApproval(pending, approved); + } }); return { createTicket, moveTicket, runLane, resolveApproval } satisfies WorkflowEngineShape; diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts index af8dc8cd0b8..af891f9c361 100644 --- a/apps/server/src/workflow/Layers/WorkflowRecovery.ts +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -8,7 +8,11 @@ import type { SqlError } from "effect/unstable/sql/SqlError"; import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; -import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderDispatchOutbox, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import type { PersistedWorkflowEvent, WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; @@ -21,6 +25,7 @@ interface DispatchRecoveryRow { readonly ticketId: TicketId; readonly stepRunId: StepRunId; readonly threadId: string; + readonly status: "pending" | "started" | "confirmed"; } interface LeaseRecoveryRow { @@ -45,6 +50,7 @@ const isTerminalStepEvent = ( const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const outbox = yield* ProviderDispatchOutbox; + const turns = yield* TurnStateReader; const approvals = yield* DurableApprovalResume; const committer = yield* WorkflowEventCommitter; const ids = yield* WorkflowIds; @@ -59,16 +65,43 @@ const make = Effect.gen(function* () { stepRunId: StepRunId, ) => events.some((event) => isTerminalStepEvent(event) && event.payload.stepRunId === stepRunId); - const commitTerminalStep = ( - row: DispatchRecoveryRow, - result: { readonly ok: boolean; readonly error?: string }, + const hasAwaitingStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, ) => + events.some( + (event) => event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId, + ); + + const commitTerminalStep = (row: DispatchRecoveryRow, result: ProviderDispatchTerminalResult) => Effect.gen(function* () { const events = yield* ticketEvents(row.ticketId); if (hasTerminalStepEvent(events, row.stepRunId)) { return; } + if ("awaitingUser" in result) { + if (hasAwaitingStepEvent(events, row.stepRunId)) { + return; + } + const eventId = yield* ids.eventId(); + const occurredAt = yield* nowIso; + yield* committer.commit({ + type: "StepAwaitingUser", + eventId, + ticketId: row.ticketId, + occurredAt, + payload: { + stepRunId: row.stepRunId, + waitingReason: result.waitingReason, + providerThreadId: result.providerThreadId, + providerRequestId: result.providerRequestId, + providerResponseKind: result.providerResponseKind, + }, + } satisfies WorkflowEventInput); + return; + } + const eventId = yield* ids.eventId(); const occurredAt = yield* nowIso; const event = result.ok @@ -95,40 +128,49 @@ const make = Effect.gen(function* () { dispatch_id AS "dispatchId", ticket_id AS "ticketId", step_run_id AS "stepRunId", - thread_id AS "threadId" + thread_id AS "threadId", + status FROM workflow_dispatch_outbox WHERE status != 'confirmed' `); for (const row of rows) { + const state = yield* turns.read(row.threadId as never); + if (state._tag === "running") { + if (row.status === "started") { + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'pending', + started_at = NULL, + turn_id = NULL + WHERE dispatch_id = ${row.dispatchId} + AND status = 'started' + `); + } + continue; + } const result = yield* outbox.awaitTerminal(row.dispatchId as never, row.threadId as never); yield* commitTerminalStep(row, result); } }); const releaseTerminalStepLeases = Effect.gen(function* () { - const events = yield* Stream.runCollect(store.readAll()).pipe( - Effect.map((chunk) => Array.from(chunk)), - ); - const terminalStepRunIds = new Set( - events.filter(isTerminalStepEvent).map((event) => event.payload.stepRunId as string), - ); - if (terminalStepRunIds.size === 0) { - return; - } - const rows = yield* wrapSql(sql` SELECT - worktree_ref AS "worktreeRef", - owner_id AS "ownerId", - fence_token AS "fenceToken" - FROM worktree_lease - WHERE owner_kind = 'step' + leases.worktree_ref AS "worktreeRef", + leases.owner_id AS "ownerId", + leases.fence_token AS "fenceToken" + FROM worktree_lease AS leases + WHERE leases.owner_kind = 'step' + AND EXISTS ( + SELECT 1 + FROM workflow_events AS events + WHERE events.event_type IN ('StepCompleted', 'StepFailed') + AND json_extract(events.payload_json, '$.stepRunId') = leases.owner_id + ) `); for (const row of rows) { - if (terminalStepRunIds.has(row.ownerId)) { - yield* leases.release(row.worktreeRef, row.fenceToken); - } + yield* leases.release(row.worktreeRef, row.fenceToken); } }); @@ -136,6 +178,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { yield* outbox.recoverPending(); yield* recoverTerminalDispatches; + yield* outbox.recoverPending(); yield* approvals.resume(); yield* releaseTerminalStepLeases; }); diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts index 0e93a2500f3..fa26c2612c1 100644 --- a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -67,6 +68,7 @@ const runtimeLayer = it.layer( Layer.succeed(WorktreePort, { ensureWorktree: (ticketId) => Effect.succeed({ + repoRoot: `/tmp/repo-${ticketId}`, worktreeRef: `wt-${ticketId}`, path: `/tmp/wt-${ticketId}`, }), @@ -83,6 +85,7 @@ const runtimeLayer = it.layer( Layer.provideMerge(DeterministicWorkflowIds), Layer.provideMerge(MigrationsLive), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts new file mode 100644 index 00000000000..2d312d7d861 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts @@ -0,0 +1,915 @@ +// @effect-diagnostics globalTimers:off +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { describe } from "vitest"; +import { BoardId, LaneKey, ProjectId, TicketId, TurnId, type VcsError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import * as GitManager from "../../git/GitManager.ts"; +import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; +import { TurnProjectionPort } from "../Services/TurnStateReader.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; +import { WorktreePortLive } from "./RealStepExecutor.ts"; +import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; + +interface ProviderCall { + readonly threadId: string; + readonly instruction: string; + readonly worktreePath: string; +} + +interface RealPathProviderDoubleShape { + readonly calls: Effect.Effect>; + readonly reset: Effect.Effect; + readonly responses: Effect.Effect>; +} + +class RealPathProviderDouble extends Context.Service< + RealPathProviderDouble, + RealPathProviderDoubleShape +>()("t3/workflow/Layers/WorkflowRuntime.realpath.test/RealPathProviderDouble") {} + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workflow-realpath-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const GitWorkflowServiceTestLayer = GitWorkflowService.layer.pipe( + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provide(Layer.mock(GitManager.GitManager)({})), +); + +const toProviderDoubleError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const RealPathProviderDoubleLive = Layer.unwrap( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const calls = yield* Ref.make>([]); + const responses = yield* Ref.make>([]); + const turnStates = yield* Ref.make>(new Map()); + + const appendInstruction = (request: DispatchRequest) => + Effect.gen(function* () { + const outputPath = path.join(request.worktreePath, "workflow-output.txt"); + const existing = yield* fileSystem + .exists(outputPath) + .pipe( + Effect.flatMap((exists) => + exists ? fileSystem.readFileString(outputPath) : Effect.succeed(""), + ), + ); + yield* fileSystem.writeFileString(outputPath, `${existing}${request.instruction}\n`); + }); + + const providerTurnPort = ProviderTurnPort.of({ + ensureTurnStarted: (request) => + Effect.gen(function* () { + const threadKey = request.threadId as string; + const turnId = TurnId.make(`turn-${threadKey}`); + yield* Ref.update(calls, (current) => [ + ...current, + { + threadId: threadKey, + instruction: request.instruction, + worktreePath: request.worktreePath, + }, + ]); + yield* Ref.update(turnStates, (current) => new Map(current).set(threadKey, "running")); + yield* appendInstruction(request); + if (request.instruction.includes("ASK_PROVIDER_QUESTION")) { + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + ${`request-${threadKey}`}, + ${threadKey}, + ${turnId}, + 'pending', + NULL, + '2026-06-07T00:00:00.000Z', + NULL + ) + `; + return { turnId }; + } + yield* Ref.update(turnStates, (current) => new Map(current).set(threadKey, "completed")); + return { turnId }; + }).pipe(Effect.mapError(toProviderDoubleError("provider double turn failed"))), + }); + + const providerResponsePort = ProviderResponsePort.of({ + respond: (input) => + Effect.gen(function* () { + yield* Ref.update(responses, (current) => [...current, input]); + yield* Ref.update(turnStates, (current) => + new Map(current).set(input.threadId as string, "completed"), + ); + yield* sql` + UPDATE projection_pending_approvals + SET status = 'resolved', + decision = ${input.approved ? "accept" : "decline"}, + resolved_at = '2026-06-07T00:00:01.000Z' + WHERE request_id = ${input.requestId} + `; + }).pipe(Effect.mapError(toProviderDoubleError("provider double response failed"))), + }); + + const turnProjectionPort = TurnProjectionPort.of({ + getLatestTurnState: (threadId) => + Ref.get(turnStates).pipe( + Effect.map((current) => { + const state = current.get(threadId as string) ?? "running"; + return { + state, + completed: state === "completed", + }; + }), + ), + }); + + const tracker = RealPathProviderDouble.of({ + calls: Ref.get(calls), + reset: Effect.all( + [Ref.set(calls, []), Ref.set(responses, []), Ref.set(turnStates, new Map())], + { discard: true }, + ), + responses: Ref.get(responses), + }); + + return Layer.mergeAll( + Layer.succeed(ProviderTurnPort, providerTurnPort), + Layer.succeed(ProviderResponsePort, providerResponsePort), + Layer.succeed(TurnProjectionPort, turnProjectionPort), + Layer.succeed(RealPathProviderDouble, tracker), + ); + }), +); + +const TestLayer = Layer.mergeAll(WorkflowRuntimeCoreLive, TicketDiffQueryLive).pipe( + Layer.provideMerge(RealPathProviderDoubleLive), + Layer.provideMerge(WorktreePortLive), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(WorktreeDiffPortLive), + Layer.provideMerge( + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(GitWorkflowServiceTestLayer), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "workflow-realpath-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const makeDirectory = ( + directoryPath: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(directoryPath, { recursive: true }); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "WorkflowRuntime.realpath.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# workflow repo\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +const withProcessCwd = ( + cwd: string, + effect: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const previous = process.cwd(); + yield* Effect.sync(() => process.chdir(cwd)); + return yield* effect.pipe(Effect.ensuring(Effect.sync(() => process.chdir(previous)))); + }); + +const waitForDetail = ( + read: WorkflowReadModel["Service"], + ticketId: TicketId, + predicate: (detail: TicketDetail | null) => boolean, + label: string, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 80; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId); + if (predicate(detail)) { + return detail; + } + yield* TestClock.adjust("50 millis"); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))); + yield* Effect.yieldNow; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const seedProject = (projectId: ProjectId, repoRoot: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${projectId}, + 'Workflow project', + ${repoRoot}, + NULL, + '[]', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + NULL + ) + `; + }); + +const registerBoardProjection = (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly repoRoot: string; +}) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: input.name, + workflowFilePath: path.join(input.repoRoot, ".t3", "boards", "delivery.json"), + workflowVersionHash: "test", + maxConcurrentTickets: 1, + }); + }); + +describe.sequential("Workflow runtime real path", () => { + it.effect("runs a two-step agent pipeline in one project worktree with accumulated diff", () => + Effect.gen(function* () { + const targetRepo = yield* makeTmpDir("workflow-target-repo-"); + const wrongRepo = yield* makeTmpDir("workflow-wrong-cwd-"); + yield* initRepoWithCommit(targetRepo); + yield* initRepoWithCommit(wrongRepo); + yield* makeDirectory(path.join(targetRepo, "prompts")); + yield* writeTextFile(path.join(targetRepo, "prompts", "step-one.md"), "first file prompt"); + + const boardId = BoardId.make("board-realpath"); + const projectId = ProjectId.make("project-realpath"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, targetRepo); + yield* registry.register(boardId, { + name: "Real path board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: { file: "prompts/step-one.md" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "second inline prompt", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Real path board", + repoRoot: targetRepo, + }); + + const { ticketId, done } = yield* withProcessCwd( + wrongRepo, + Effect.gen(function* () { + const ticketId = yield* engine.createTicket({ + boardId, + title: "Ship a real worktree ticket", + initialLane: LaneKey.make("implement"), + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => + detail?.ticket.currentLaneKey === "done" || + detail?.ticket.currentLaneKey === "needs_attention", + "terminal lane", + ); + return { ticketId, done }; + }), + ); + const calls = yield* provider.calls; + + assert.equal(done?.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.worktreePath, calls[1]?.worktreePath); + assert.isTrue((calls[0]?.worktreePath ?? "").includes(path.basename(targetRepo))); + assert.equal(calls[0]?.instruction, "first file prompt"); + assert.equal(calls[1]?.instruction, "second inline prompt"); + assert.match( + yield* git(targetRepo, ["branch", "--list", "workflow/ticket-1"]), + /workflow\/ticket-1/, + ); + assert.equal(yield* git(wrongRepo, ["branch", "--list", "workflow/ticket-1"]), ""); + + const ticketDiff = yield* TicketDiffQuery; + const diff = yield* ticketDiff.getTicketDiff( + ticketId, + calls[0]?.worktreePath ?? "", + ticketBaseRef(ticketId), + ); + assert.include(diff.patch, "+first file prompt"); + assert.include(diff.patch, "+second inline prompt"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("surfaces a real provider question as waiting_on_user and resumes the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-question-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-question"); + const projectId = ProjectId.make("project-question"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Question board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION", + }, + { + key: "continue", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after answer", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Question board", + repoRoot: repo, + }); + + const ticketId = yield* withProcessCwd( + repo, + engine.createTicket({ + boardId, + title: "Question ticket", + initialLane: LaneKey.make("implement"), + }), + ); + const waiting = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status === "waiting_on_user", + "provider question", + ); + if (waiting === null) { + assert.fail("Expected provider question detail"); + } + const awaitingStep = waiting.steps.find((step) => step.status === "awaiting_user"); + assert.isDefined(awaitingStep); + + yield* engine.resolveApproval(awaitingStep?.stepRunId as never, true); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "question pipeline completion", + ); + if (done === null) { + assert.fail("Expected completed question detail"); + } + const calls = yield* provider.calls; + const responses = yield* provider.responses; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 2); + assert.deepEqual( + responses.map((response) => response.responseKind), + ["request"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "recovery returns promptly for a non-terminal dispatch whose provider session is gone", + () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-recovery-repo-"); + yield* initRepoWithCommit(repo); + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-nonterminal', + 'ticket-nonterminal', + 'step-nonterminal', + 'thread-nonterminal', + 'codex', + 'gpt-5.5', + 'recover without hanging', + ${repo}, + 'started', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + + const fiber = yield* Effect.forkChild(recovery.recover()); + let completed = false; + for (let attempt = 0; attempt < 20; attempt += 1) { + const exit = yield* Effect.sync(() => fiber.pollUnsafe()); + if (exit !== undefined) { + completed = true; + break; + } + yield* TestClock.adjust("100 millis"); + yield* Effect.yieldNow; + } + if (!completed) { + yield* Fiber.interrupt(fiber); + assert.fail("Timed out waiting for workflow recovery to return"); + } + yield* Fiber.join(fiber); + const calls = yield* provider.calls; + + assert.deepEqual( + calls.map((call) => call.threadId), + ["thread-nonterminal"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes an approval step across restart and continues the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-approval-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-approval-restart"); + const projectId = ProjectId.make("project-approval-restart"); + const ticketId = TicketId.make("ticket-approval-restart"); + const approvalStepRunId = "step-approval-restart" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Approval restart board", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "approve", + type: "approval", + prompt: "Approve continuing?", + }, + { + key: "after-approval", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after durable approval", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Approval restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-approval-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Approval restart ticket", + laneKey: LaneKey.make("review"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-approval-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("review"), + laneEntryToken: "token-approval-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-approval-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-approval-restart" as never, + laneKey: LaneKey.make("review"), + laneEntryToken: "token-approval-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-approval-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-approval-restart" as never, + stepRunId: approvalStepRunId, + stepKey: "approve" as never, + stepType: "approval", + }, + }); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-approval-awaiting-user" as never, + ticketId, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: approvalStepRunId, + waitingReason: "Approve continuing?", + }, + }); + + yield* recovery.recover(); + yield* engine.resolveApproval(approvalStepRunId, true); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "approval restart completion", + ); + if (done === null) { + assert.fail("Expected completed approval restart detail"); + } + const calls = yield* provider.calls; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 1); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes a provider question across restart and continues the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-provider-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-provider-restart"); + const projectId = ProjectId.make("project-provider-restart"); + const ticketId = TicketId.make("ticket-provider-restart"); + const stepRunId = "step-provider-restart" as never; + const threadId = "thread-provider-restart" as never; + const requestId = "request-provider-restart" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + const sql = yield* SqlClient.SqlClient; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Provider restart board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION", + }, + { + key: "continue", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after durable provider question", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Provider restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-provider-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Provider restart ticket", + laneKey: LaneKey.make("implement"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-provider-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("implement"), + laneEntryToken: "token-provider-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-provider-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-restart" as never, + laneKey: LaneKey.make("implement"), + laneEntryToken: "token-provider-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-provider-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-restart" as never, + stepRunId, + stepKey: "ask" as never, + stepType: "agent", + }, + }); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-provider-awaiting-user" as never, + ticketId, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId, + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: requestId, + providerResponseKind: "request", + }, + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-restart', + ${ticketId}, + ${stepRunId}, + ${threadId}, + 'codex', + 'gpt-5.5', + 'ASK_PROVIDER_QUESTION', + ${repo}, + 'started', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + ${requestId}, + ${threadId}, + 'turn-provider-restart', + 'pending', + NULL, + '2026-06-07T00:00:04.000Z', + NULL + ) + `; + + yield* recovery.recover(); + yield* engine.resolveApproval(stepRunId, true); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "provider restart completion", + ); + if (done === null) { + assert.fail("Expected completed provider restart detail"); + } + const calls = yield* provider.calls; + const responses = yield* provider.responses; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 1); + assert.deepEqual( + responses.map((response) => response.requestId), + [requestId], + ); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/workflow/Services/ApprovalGate.ts b/apps/server/src/workflow/Services/ApprovalGate.ts index 27cef81968c..aee2c5153c3 100644 --- a/apps/server/src/workflow/Services/ApprovalGate.ts +++ b/apps/server/src/workflow/Services/ApprovalGate.ts @@ -5,7 +5,7 @@ import type * as Effect from "effect/Effect"; export interface ApprovalGateShape { readonly park: (stepRunId: StepRunId) => Effect.Effect; readonly await: (stepRunId: StepRunId) => Effect.Effect; - readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect; + readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect; } export class ApprovalGate extends Context.Service()( diff --git a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts index b2c4fa70603..da37df1a168 100644 --- a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts +++ b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts @@ -1,4 +1,11 @@ -import type { DispatchId, StepRunId, ThreadId, TicketId, TurnId } from "@t3tools/contracts"; +import type { + ApprovalRequestId, + DispatchId, + StepRunId, + ThreadId, + TicketId, + TurnId, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -25,12 +32,25 @@ export class ProviderTurnPort extends Context.Service Effect.Effect; readonly ensureStarted: (req: DispatchRequest) => Effect.Effect; readonly awaitTerminal: ( dispatchId: DispatchId, threadId: ThreadId, - ) => Effect.Effect<{ readonly ok: boolean; readonly error?: string }, WorkflowEventStoreError>; + ) => Effect.Effect; readonly recoverPending: () => Effect.Effect; } diff --git a/apps/server/src/workflow/Services/TurnStateReader.ts b/apps/server/src/workflow/Services/TurnStateReader.ts index 431eb021a82..1a8ef1afefb 100644 --- a/apps/server/src/workflow/Services/TurnStateReader.ts +++ b/apps/server/src/workflow/Services/TurnStateReader.ts @@ -1,10 +1,17 @@ -import type { ThreadId } from "@t3tools/contracts"; +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; export type TurnState = | { readonly _tag: "running" } | { readonly _tag: "completed" } + | { + readonly _tag: "awaiting_user"; + readonly waitingReason: string; + readonly providerThreadId: ThreadId; + readonly providerRequestId: ApprovalRequestId; + readonly providerResponseKind: "request" | "user-input"; + } | { readonly _tag: "failed"; readonly error: string }; export interface TurnProjectionPortShape { diff --git a/apps/server/src/workflow/Services/WorktreePort.ts b/apps/server/src/workflow/Services/WorktreePort.ts index 07d5e7cadee..99b2d4fe171 100644 --- a/apps/server/src/workflow/Services/WorktreePort.ts +++ b/apps/server/src/workflow/Services/WorktreePort.ts @@ -5,6 +5,7 @@ import type * as Effect from "effect/Effect"; import type { WorkflowEventStoreError } from "./Errors.ts"; export interface WorktreeHandle { + readonly repoRoot: string; readonly worktreeRef: string; readonly path: string; } diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 62c53490224..ebe23fab4d3 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -244,7 +244,13 @@ export type WorkflowEvent = typeof WorkflowEvent.Type; export const StepOutcome = Schema.Union([ Schema.Struct({ _tag: Schema.Literal("completed") }), Schema.Struct({ _tag: Schema.Literal("failed"), error: Schema.String }), - Schema.Struct({ _tag: Schema.Literal("awaiting_user"), waitingReason: Schema.String }), + Schema.Struct({ + _tag: Schema.Literal("awaiting_user"), + waitingReason: Schema.String, + providerThreadId: Schema.optional(ThreadId), + providerRequestId: Schema.optional(ApprovalRequestId), + providerResponseKind: Schema.optional(Schema.Literals(["request", "user-input"])), + }), ]); export type StepOutcome = typeof StepOutcome.Type; From 7079e2355e27b891fac2c453d715f5a0fa3bbdc3 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 13:24:52 -0400 Subject: [PATCH 053/295] Make workflow recovery honor live provider turn state Recovery now interrupts stale pre-restart projection turns, restarts provider dispatches without rebinding to dead turns, and hands terminal results back to the engine so autonomous pipelines route after restart. Provider question waits now come from real user-input activity projection and the engine re-awaits terminal completion after answers before starting the next step. Constraint: Fix reviewed residuals without adding v2 workflow features or weakening stub-free real-path tests. Rejected: Stubbing TurnProjectionPort or pending approvals in real-path coverage | Those were the exact seams hiding the restart and user-input bugs. Confidence: high Scope-risk: moderate Directive: Keep provider wait completion tied to dispatch terminal state; do not confirm a step merely because the user answered a provider question. Tested: cd apps/server && pnpm exec vp test run src/workflow; pnpm exec vp run typecheck; pnpm exec vp check Not-tested: Full application browser workflow; changes are server workflow runtime only. --- apps/server/src/server.test.ts | 1 + .../workflow/Layers/ProviderDispatchOutbox.ts | 30 +- .../workflow/Layers/RealStepExecutor.test.ts | 71 ++- .../src/workflow/Layers/RealStepExecutor.ts | 17 +- .../src/workflow/Layers/TurnStateReader.ts | 56 ++ .../Layers/WorkflowEngine.integration.test.ts | 36 +- .../src/workflow/Layers/WorkflowEngine.ts | 206 +++++-- .../workflow/Layers/WorkflowRecovery.test.ts | 10 + .../src/workflow/Layers/WorkflowRecovery.ts | 69 +++ .../Layers/WorkflowRpcHandlers.test.ts | 1 + .../Layers/WorkflowRuntime.realpath.test.ts | 575 ++++++++++++++++-- .../Services/ProviderDispatchOutbox.ts | 4 + .../src/workflow/Services/WorkflowEngine.ts | 8 + .../src/workflow/WorkflowRuntimeLive.ts | 2 +- 14 files changed, 937 insertions(+), 149 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0d747ba03f4..9576be90f56 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -549,6 +549,7 @@ const buildAppUnderTest = (options?: { moveTicket: () => Effect.die("unused workflow moveTicket"), runLane: () => Effect.die("unused workflow runLane"), resolveApproval: () => Effect.die("unused workflow resolveApproval"), + completeRecoveredStep: () => Effect.die("unused workflow completeRecoveredStep"), }), Layer.mock(WorkflowReadModel)({ registerBoard: () => Effect.void, diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts index 65b714702bb..14c5c91b5b1 100644 --- a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts @@ -42,6 +42,10 @@ interface RecoverDispatchRow extends DispatchRequest { readonly status: "pending" | "started" | "confirmed"; } +interface StepDispatchRow { + readonly dispatchId: string; +} + const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const provider = yield* ProviderTurnPort; @@ -160,6 +164,27 @@ const make = Effect.gen(function* () { ); }; + const awaitStepTerminal: ProviderDispatchOutboxShape["awaitStepTerminal"] = ( + stepRunId, + threadId, + ) => + Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT dispatch_id AS "dispatchId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `); + const dispatchId = rows[0]?.dispatchId; + if (!dispatchId) { + return yield* new WorkflowEventStoreError({ + message: `dispatch not found for step ${stepRunId}`, + }); + } + return yield* awaitTerminal(dispatchId as never, threadId); + }); + const recoverPending: ProviderDispatchOutboxShape["recoverPending"] = () => Effect.gen(function* () { const rows = yield* wrapSql(sql` @@ -188,6 +213,7 @@ const make = Effect.gen(function* () { confirmStep, ensureStarted, awaitTerminal, + awaitStepTerminal, recoverPending, } satisfies ProviderDispatchOutboxShape; }); @@ -205,7 +231,9 @@ export const ProviderTurnPortLive = Layer.effect( const existingTurns = yield* turns .listByThreadId({ threadId: req.threadId }) .pipe(Effect.orElseSucceed(() => [])); - const existingTurn = existingTurns.findLast((turn) => turn.turnId !== null); + const existingTurn = existingTurns.findLast( + (turn) => turn.turnId !== null && (turn.state === "pending" || turn.state === "running"), + ); if (existingTurn?.turnId !== undefined && existingTurn.turnId !== null) { return { turnId: existingTurn.turnId }; } diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts index 412e39bd4d9..123eb61ca8d 100644 --- a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -40,6 +40,21 @@ const context: StepExecutionContext = { }, }; +const fileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-file-instruction" as never, + stepRunId: "step-run-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "missing-instruction.md" }, + }, +}; + const checkpointCalls: Array = []; const preRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/pre"; const postRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/post"; @@ -87,6 +102,7 @@ const mk = (terminal: ProviderDispatchTerminalResult) => confirmStep: () => Effect.void, ensureStarted: () => Effect.void, awaitTerminal: () => Effect.succeed(terminal), + awaitStepTerminal: () => Effect.succeed(terminal), recoverPending: () => Effect.void, }), ), @@ -99,21 +115,24 @@ const mk = (terminal: ProviderDispatchTerminalResult) => ), ); -const seedStepStarted = Effect.gen(function* () { - const committer = yield* WorkflowEventCommitter; - yield* committer.commit({ - type: "StepStarted", - eventId: "event-step-started" as never, - ticketId: context.ticketId, - occurredAt: "2026-06-07T00:00:00.000Z" as never, - payload: { - pipelineRunId: context.pipelineRunId, - stepRunId: context.stepRunId, - stepKey: context.step.key, - stepType: "agent", - }, +const seedStepStartedFor = (ctx: StepExecutionContext, eventId: string) => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + yield* committer.commit({ + type: "StepStarted", + eventId: eventId as never, + ticketId: ctx.ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + pipelineRunId: ctx.pipelineRunId, + stepRunId: ctx.stepRunId, + stepKey: ctx.step.key, + stepType: "agent", + }, + }); }); -}); + +const seedStepStarted = seedStepStartedFor(context, "event-step-started"); const assertProjectedStepRefs = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -139,6 +158,11 @@ const assertProjectedStepRefs = Effect.gen(function* () { assert.equal(rows[0]?.postCheckpointRef, postRef); }); +const seedFileInstructionStepStarted = seedStepStartedFor( + fileInstructionContext, + "event-step-started-file-instruction", +); + mk({ ok: true })("RealStepExecutor success", (it) => { it.effect("completes an agent step and releases the worktree lease", () => Effect.gen(function* () { @@ -165,6 +189,24 @@ mk({ ok: true })("RealStepExecutor success", (it) => { yield* assertProjectedStepRefs; }), ); + + it.effect("releases the worktree lease when instruction file resolution fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedFileInstructionStepStarted; + + const outcome = yield* executor.execute(fileInstructionContext); + + assert.deepEqual(outcome, { _tag: "failed", error: "executor error" }); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); }); mk({ ok: false, error: "provider failed" })("RealStepExecutor failure", (it) => { @@ -221,6 +263,7 @@ const preCheckpointFailureLayer = it.layer( confirmStep: () => Effect.void, ensureStarted: () => Effect.void, awaitTerminal: () => Effect.succeed({ ok: true }), + awaitStepTerminal: () => Effect.succeed({ ok: true }), recoverPending: () => Effect.void, }), ), diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts index e259e71d77e..a70d27b19c2 100644 --- a/apps/server/src/workflow/Layers/RealStepExecutor.ts +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -69,15 +69,6 @@ const make = Effect.gen(function* () { } const acquired = yield* lease.acquire(worktree.worktreeRef, "step", ctx.stepRunId as string); - const dispatchId = yield* ids.eventId(); - const threadId = yield* ids.eventId(); - const instruction = - typeof step.instruction === "string" - ? step.instruction - : yield* fileSystem - .readFileString(path.resolve(worktree.repoRoot, step.instruction.file)) - .pipe(Effect.mapError(toExecutorError("instruction file read failed"))); - const releaseIfStillOwner = lease.isValid(worktree.worktreeRef, acquired.fenceToken).pipe( Effect.flatMap((valid) => valid ? lease.release(worktree.worktreeRef, acquired.fenceToken) : Effect.void, @@ -86,6 +77,14 @@ const make = Effect.gen(function* () { ); const result = yield* Effect.gen(function* () { + const dispatchId = yield* ids.eventId(); + const threadId = yield* ids.eventId(); + const instruction = + typeof step.instruction === "string" + ? step.instruction + : yield* fileSystem + .readFileString(path.resolve(worktree.repoRoot, step.instruction.file)) + .pipe(Effect.mapError(toExecutorError("instruction file read failed"))); const preRef = yield* ticketCheckpoints.captureStep( ctx.ticketId, ctx.stepRunId, diff --git a/apps/server/src/workflow/Layers/TurnStateReader.ts b/apps/server/src/workflow/Layers/TurnStateReader.ts index 9973ba70b0d..8a4e29a36c6 100644 --- a/apps/server/src/workflow/Layers/TurnStateReader.ts +++ b/apps/server/src/workflow/Layers/TurnStateReader.ts @@ -16,6 +16,10 @@ interface PendingProviderRequestRow { readonly requestId: string; } +interface PendingUserInputRow { + readonly requestId: string; +} + const toTurnState = (state: string): TurnState => { if (state === "completed") { return { _tag: "completed" }; @@ -43,6 +47,48 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); + const pendingUserInputRequest = (threadId: ThreadId) => + sql` + WITH latest_user_input_states AS ( + SELECT + latest.request_id AS "requestId", + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = ${threadId} + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT "requestId" + FROM latest_user_input_states + WHERE kind = 'user-input.requested' + OR ( + kind = 'provider.user-input.respond.failed' + AND detail NOT LIKE '%stale pending user-input request%' + AND detail NOT LIKE '%unknown pending user-input request%' + ) + ORDER BY "requestId" ASC + LIMIT 1 + `.pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.orElseSucceed(() => null), + ); + const read: TurnStateReaderShape["read"] = (threadId) => Effect.gen(function* () { const { state } = yield* port.getLatestTurnState(threadId); @@ -61,6 +107,16 @@ const make = Effect.gen(function* () { providerResponseKind: "request", } satisfies TurnState; } + const pendingUserInput = yield* pendingUserInputRequest(threadId); + if (pendingUserInput) { + return { + _tag: "awaiting_user", + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: ApprovalRequestId.make(pendingUserInput.requestId), + providerResponseKind: "user-input", + } satisfies TurnState; + } return turnState; }); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index 37fb91f2c0e..533eaa5a4f4 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -43,11 +43,14 @@ const definition = { ], }; -const baseLayer = (executor: Layer.Layer) => +const baseLayer = ( + executor: Layer.Layer, + boardRegistry: Layer.Layer = BoardRegistryLive, +) => WorkflowEngineLayer.pipe( Layer.provideMerge(executor), Layer.provideMerge(ApprovalGateLive), - Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(boardRegistry), Layer.provideMerge(DeterministicWorkflowIds), Layer.provideMerge(WorkflowEventCommitterLive), Layer.provideMerge(WorkflowFoundationLive), @@ -168,6 +171,35 @@ explodingLayer("WorkflowEngine pipeline error handling", (it) => { ); }); +const failingDefinitionRegistry = Layer.succeed(BoardRegistry, { + register: () => Effect.succeed(definition as never), + getLane: (_boardId, laneKey) => + Effect.succeed((definition.lanes.find((lane) => lane.key === laneKey) ?? null) as never), + getDefinition: () => Effect.die("definition unavailable"), +}); + +const pipelineFailureLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }), failingDefinitionRegistry), +); + +pipelineFailureLayer("WorkflowEngine orchestration error handling", (it) => { + it.effect("blocks the ticket when pipeline orchestration fails before the first step", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pipeline-fails" as never, + title: "Pipeline fails", + initialLane: "impl" as never, + }); + + const detail = yield* awaitStatus(ticketId as string, "blocked"); + assert.equal(detail?.ticket.status, "blocked"); + assert.equal(detail?.steps.length, 0); + }), + ); +}); + const approvalDefinition = { name: "approval-wf", lanes: [ diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index 4dea7d32fd7..d797dfcbb40 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -5,11 +5,13 @@ import type { PipelineRunId, StepOutcome, StepRunId, + ThreadId, TicketId, WorkflowEventId, WorkflowLane, WorkflowStep, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -28,7 +30,11 @@ import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; import { StepExecutor } from "../Services/StepExecutor.ts"; -import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { + WorkflowEngine, + type RecoveredStepResult, + type WorkflowEngineShape, +} from "../Services/WorkflowEngine.ts"; import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import { WorkflowEventStore, @@ -135,6 +141,25 @@ const make = Effect.gen(function* () { return pending; }; + const hasTerminalStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => + events.some( + (event) => + (event.type === "StepCompleted" || event.type === "StepFailed") && + event.payload.stepRunId === stepRunId, + ); + + const hasPipelineCompletedEvent = ( + events: ReadonlyArray, + pipelineRunId: PipelineRunId, + ) => + events.some( + (event) => + event.type === "PipelineCompleted" && event.payload.pipelineRunId === pipelineRunId, + ); + const pendingWaitFor = (stepRunId: StepRunId) => Effect.gen(function* () { const events = yield* readStoredEventsForStep(stepRunId); @@ -209,6 +234,29 @@ const make = Effect.gen(function* () { } }); + const awaitProviderTerminalForStep = (stepRunId: StepRunId, threadId: ThreadId) => + Effect.gen(function* () { + const { providerDispatches } = yield* getOptionalServices; + if (Option.isNone(providerDispatches)) { + return { _tag: "completed" } satisfies RecoveredStepResult; + } + + const result = yield* providerDispatches.value.awaitStepTerminal(stepRunId, threadId); + if (result.ok) { + return { _tag: "completed" } satisfies RecoveredStepResult; + } + if ("awaitingUser" in result) { + return { + _tag: "failed", + error: "provider requested additional user input", + } satisfies RecoveredStepResult; + } + return { + _tag: "failed", + error: result.error ?? "turn failed", + } satisfies RecoveredStepResult; + }); + const runStep = ( ticketId: TicketId, boardId: BoardId, @@ -278,14 +326,6 @@ const make = Effect.gen(function* () { }); const approved = yield* approvals.await(stepRunId); yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); - const { providerDispatches } = yield* getOptionalServices; - if ( - outcome.providerThreadId !== undefined && - outcome.providerRequestId !== undefined && - Option.isSome(providerDispatches) - ) { - yield* providerDispatches.value.confirmStep(stepRunId); - } if (!approved) { yield* commit({ type: "StepFailed", @@ -294,6 +334,20 @@ const make = Effect.gen(function* () { }); return "failed"; } + if (outcome.providerThreadId !== undefined) { + const terminalResult = yield* awaitProviderTerminalForStep( + stepRunId, + outcome.providerThreadId, + ); + if (terminalResult._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId, + payload: { stepRunId, error: terminalResult.error }, + }); + return "failed"; + } + } yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); return "completed"; } @@ -321,7 +375,18 @@ const make = Effect.gen(function* () { const permits = Math.max(1, definition?.settings?.maxConcurrentTickets ?? 3); const semaphore = yield* semaphoreFor(boardId, permits); yield* semaphore.withPermits(1)(runPipelineBody(ticketId, boardId, lane, laneEntryToken)); - }).pipe(Effect.catch(() => Effect.void)); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + return commit({ + type: "TicketBlocked", + ticketId, + payload: { reason: `pipeline error: ${Cause.pretty(cause)}` }, + }).pipe(Effect.catch(() => Effect.void)); + }), + ); const completePipelineFrom = ( ticketId: TicketId, @@ -506,27 +571,27 @@ const make = Effect.gen(function* () { } }); - const recoveredApprovalContext = ( + const recoveredStepContext = ( events: ReadonlyArray, - pending: PendingWait, + stepRunId: StepRunId, ) => { let stepStarted: StepStarted | null = null; let pipelineStarted: PipelineStarted | null = null; let ticketCreated: TicketCreated | null = null; for (const event of events) { - if (event.type === "StepStarted" && event.payload.stepRunId === pending.payload.stepRunId) { + if (event.type === "StepStarted" && event.payload.stepRunId === stepRunId) { stepStarted = event; } - if (event.type === "TicketCreated" && event.ticketId === pending.ticketId) { - ticketCreated = event; - } } if (!stepStarted) { return null; } for (const event of events) { + if (event.type === "TicketCreated" && event.ticketId === stepStarted.ticketId) { + ticketCreated = event; + } if ( event.type === "PipelineStarted" && event.payload.pipelineRunId === stepStarted.payload.pipelineRunId @@ -541,15 +606,18 @@ const make = Effect.gen(function* () { return { stepStarted, pipelineStarted, ticketCreated }; }; - const continueRecoveredApproval = (pending: PendingWait, approved: boolean) => + const completeRecoveredStep: WorkflowEngineShape["completeRecoveredStep"] = (stepRunId, result) => Effect.gen(function* () { - const events = yield* readStoredEventsForStep(pending.payload.stepRunId); - if (events === null || !pendingWaitInEvents(events, pending.payload.stepRunId)) { + const events = yield* readStoredEventsForStep(stepRunId); + if (events === null) { return; } - const recovered = recoveredApprovalContext(events, pending); - if (!recovered) { + const recovered = recoveredStepContext(events, stepRunId); + if ( + !recovered || + hasPipelineCompletedEvent(events, recovered.pipelineStarted.payload.pipelineRunId) + ) { return; } @@ -568,53 +636,67 @@ const make = Effect.gen(function* () { return; } + if (!hasTerminalStepEvent(events, stepRunId)) { + if (result._tag === "completed") { + yield* commit({ + type: "StepCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: { stepRunId }, + }); + } else { + yield* commit({ + type: "StepFailed", + ticketId: recovered.stepStarted.ticketId, + payload: { stepRunId, error: result.error }, + }); + } + } + + yield* completePipelineFrom( + recovered.stepStarted.ticketId, + boardId, + lane, + laneEntryToken, + recovered.pipelineStarted.payload.pipelineRunId, + steps, + result._tag === "completed" ? currentStepIndex + 1 : steps.length, + result._tag === "completed" ? "success" : "failure", + ); + }); + + const continueRecoveredApproval = (pending: PendingWait, approved: boolean) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(pending.payload.stepRunId); + if (events === null || !pendingWaitInEvents(events, pending.payload.stepRunId)) { + return; + } + + const recovered = recoveredStepContext(events, pending.payload.stepRunId); + if (!recovered) { + return; + } + yield* commit({ type: "StepUserResolved", ticketId: pending.ticketId, payload: { stepRunId: pending.payload.stepRunId }, }); - const { providerDispatches } = yield* getOptionalServices; - if ( - pending.payload.providerThreadId && - pending.payload.providerRequestId && - Option.isSome(providerDispatches) - ) { - yield* providerDispatches.value.confirmStep(pending.payload.stepRunId); - } if (!approved) { - yield* commit({ - type: "StepFailed", - ticketId: pending.ticketId, - payload: { stepRunId: pending.payload.stepRunId, error: "rejected" }, + yield* completeRecoveredStep(pending.payload.stepRunId, { + _tag: "failed", + error: "rejected", }); - yield* completePipelineFrom( - pending.ticketId, - boardId, - lane, - laneEntryToken, - recovered.pipelineStarted.payload.pipelineRunId, - steps, - steps.length, - "failure", - ); return; } - yield* commit({ - type: "StepCompleted", - ticketId: pending.ticketId, - payload: { stepRunId: pending.payload.stepRunId }, - }); - yield* completePipelineFrom( - pending.ticketId, - boardId, - lane, - laneEntryToken, - recovered.pipelineStarted.payload.pipelineRunId, - steps, - currentStepIndex + 1, - "success", - ); + const terminalResult = + pending.payload.providerThreadId === undefined + ? ({ _tag: "completed" } satisfies RecoveredStepResult) + : yield* awaitProviderTerminalForStep( + pending.payload.stepRunId, + pending.payload.providerThreadId, + ); + yield* completeRecoveredStep(pending.payload.stepRunId, terminalResult); }); const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => @@ -641,7 +723,13 @@ const make = Effect.gen(function* () { } }); - return { createTicket, moveTicket, runLane, resolveApproval } satisfies WorkflowEngineShape; + return { + createTicket, + moveTicket, + runLane, + resolveApproval, + completeRecoveredStep, + } satisfies WorkflowEngineShape; }); export const WorkflowEngineLayer = Layer.effect(WorkflowEngine, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts index eadcc4ca7bd..74e896e8546 100644 --- a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts @@ -13,6 +13,7 @@ import { TurnStateReader } from "../Services/TurnStateReader.ts"; import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; import { WorkflowIds } from "../Services/WorkflowIds.ts"; import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; @@ -25,6 +26,15 @@ const layer = it.layer( Layer.provideMerge(DurableApprovalResumeLive), Layer.provideMerge(WorktreeLeaseServiceLive), Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + moveTicket: () => Effect.die("unused moveTicket"), + runLane: () => Effect.die("unused runLane"), + resolveApproval: () => Effect.die("unused resolveApproval"), + completeRecoveredStep: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(WorkflowIds, { ticketId: () => Effect.succeed("ticket-unused" as never), diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts index af891f9c361..301183fe1a1 100644 --- a/apps/server/src/workflow/Layers/WorkflowRecovery.ts +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -16,6 +16,7 @@ import { TurnStateReader } from "../Services/TurnStateReader.ts"; import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import type { PersistedWorkflowEvent, WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; import { WorkflowIds } from "../Services/WorkflowIds.ts"; import { WorkflowRecovery, type WorkflowRecoveryShape } from "../Services/WorkflowRecovery.ts"; import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; @@ -25,6 +26,7 @@ interface DispatchRecoveryRow { readonly ticketId: TicketId; readonly stepRunId: StepRunId; readonly threadId: string; + readonly turnId: string | null; readonly status: "pending" | "started" | "confirmed"; } @@ -53,6 +55,7 @@ const make = Effect.gen(function* () { const turns = yield* TurnStateReader; const approvals = yield* DurableApprovalResume; const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; const ids = yield* WorkflowIds; const store = yield* WorkflowEventStore; const leases = yield* WorktreeLeaseService; @@ -122,6 +125,35 @@ const make = Effect.gen(function* () { yield* committer.commit(event); }); + const completeTerminalPipeline = ( + row: DispatchRecoveryRow, + result: ProviderDispatchTerminalResult, + ) => + "awaitingUser" in result + ? Effect.void + : engine.completeRecoveredStep( + row.stepRunId, + result.ok + ? { _tag: "completed" } + : { _tag: "failed", error: result.error ?? "turn failed" }, + ); + + const interruptProjectedTurn = (row: DispatchRecoveryRow) => + row.turnId === null + ? Effect.void + : nowIso.pipe( + Effect.flatMap((interruptedAt) => + wrapSql(sql` + UPDATE projection_turns + SET state = 'interrupted', + completed_at = ${interruptedAt} + WHERE thread_id = ${row.threadId} + AND turn_id = ${row.turnId} + AND state IN ('pending', 'running') + `), + ), + ); + const recoverTerminalDispatches = Effect.gen(function* () { const rows = yield* wrapSql(sql` SELECT @@ -129,6 +161,7 @@ const make = Effect.gen(function* () { ticket_id AS "ticketId", step_run_id AS "stepRunId", thread_id AS "threadId", + turn_id AS "turnId", status FROM workflow_dispatch_outbox WHERE status != 'confirmed' @@ -138,6 +171,7 @@ const make = Effect.gen(function* () { const state = yield* turns.read(row.threadId as never); if (state._tag === "running") { if (row.status === "started") { + yield* interruptProjectedTurn(row); yield* wrapSql(sql` UPDATE workflow_dispatch_outbox SET status = 'pending', @@ -151,6 +185,7 @@ const make = Effect.gen(function* () { } const result = yield* outbox.awaitTerminal(row.dispatchId as never, row.threadId as never); yield* commitTerminalStep(row, result); + yield* completeTerminalPipeline(row, result); } }); @@ -174,11 +209,45 @@ const make = Effect.gen(function* () { } }); + const monitorStartedDispatches = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + turn_id AS "turnId", + status + FROM workflow_dispatch_outbox + WHERE status = 'started' + `); + + yield* Effect.forEach( + rows, + (row) => + Effect.gen(function* () { + const result = yield* outbox.awaitTerminal( + row.dispatchId as never, + row.threadId as never, + ); + yield* commitTerminalStep(row, result); + yield* completeTerminalPipeline(row, result); + yield* releaseTerminalStepLeases; + }).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkDetach({ startImmediately: true }), + Effect.asVoid, + ), + { discard: true }, + ); + }); + const recover: WorkflowRecoveryShape["recover"] = () => Effect.gen(function* () { yield* outbox.recoverPending(); yield* recoverTerminalDispatches; yield* outbox.recoverPending(); + yield* monitorStartedDispatches; yield* approvals.resume(); yield* releaseTerminalStepLeases; }); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts index d0a69f3e99e..c3bd320e2e6 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -26,6 +26,7 @@ it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => moveTicket: () => Effect.void, runLane: () => Effect.void, resolveApproval: () => Effect.void, + completeRecoveredStep: () => Effect.void, }, readModel: { getBoard: () => diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts index 2d312d7d861..7d45768cad6 100644 --- a/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts @@ -20,6 +20,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import * as GitManager from "../../git/GitManager.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { ServerConfig } from "../../config.ts"; @@ -35,7 +36,6 @@ import { } from "../Services/ProviderResponsePort.ts"; import { SetupTerminalPort } from "../Services/SetupRunService.ts"; import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; -import { TurnProjectionPort } from "../Services/TurnStateReader.ts"; import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; @@ -44,17 +44,21 @@ import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; import { WorktreePortLive } from "./RealStepExecutor.ts"; +import { TurnProjectionPortLive } from "./TurnStateReader.ts"; import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; import { ticketBaseRef } from "../ticketRefs.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; interface ProviderCall { readonly threadId: string; readonly instruction: string; + readonly turnId: string; readonly worktreePath: string; } interface RealPathProviderDoubleShape { readonly calls: Effect.Effect>; + readonly completeThread: (threadId: string) => Effect.Effect; readonly reset: Effect.Effect; readonly responses: Effect.Effect>; } @@ -93,7 +97,8 @@ const RealPathProviderDoubleLive = Layer.unwrap( const sql = yield* SqlClient.SqlClient; const calls = yield* Ref.make>([]); const responses = yield* Ref.make>([]); - const turnStates = yield* Ref.make>(new Map()); + const heldAfterAnswerThreads = yield* Ref.make>(new Set()); + const turnCounters = yield* Ref.make>(new Map()); const appendInstruction = (request: DispatchRequest) => Effect.gen(function* () { @@ -108,45 +113,168 @@ const RealPathProviderDoubleLive = Layer.unwrap( yield* fileSystem.writeFileString(outputPath, `${existing}${request.instruction}\n`); }); + const upsertTurnState = (input: { + readonly threadId: string; + readonly turnId: string; + readonly state: "running" | "completed" | "interrupted"; + }) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${input.threadId}, + ${input.turnId}, + NULL, + NULL, + NULL, + NULL, + ${input.state}, + '2026-06-07T00:01:00.000Z', + '2026-06-07T00:01:00.000Z', + ${input.state === "running" ? null : "2026-06-07T00:01:01.000Z"}, + NULL, + NULL, + NULL, + '[]' + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + state = excluded.state, + completed_at = excluded.completed_at + `.pipe(Effect.mapError(toProviderDoubleError("provider double turn state failed"))); + + const nextTurnId = (threadId: string) => + Ref.modify(turnCounters, (current) => { + const nextValue = (current.get(threadId) ?? 0) + 1; + const next = new Map(current); + next.set(threadId, nextValue); + return [`turn-${threadId}-${nextValue}`, next] as const; + }); + + const activeProjectedTurn = (threadId: string) => + sql<{ readonly turnId: string; readonly state: string }>` + SELECT turn_id AS "turnId", state + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + ORDER BY requested_at ASC, turn_id ASC + `.pipe( + Effect.map( + (rows) => + rows.findLast((row) => row.state === "pending" || row.state === "running") ?? null, + ), + Effect.mapError(toProviderDoubleError("provider double active turn lookup failed")), + ); + + const insertUserInputRequest = (threadId: string, turnId: string) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${`activity-user-input-requested-${threadId}`}, + ${threadId}, + ${turnId}, + 'approval', + 'user-input.requested', + 'Question for workflow', + ${JSON.stringify({ requestId: `request-${threadId}` })}, + 1, + '2026-06-07T00:00:00.000Z' + ) + `.pipe(Effect.mapError(toProviderDoubleError("provider double user input failed"))); + + const insertUserInputResolved = (input: ProviderResponseInput) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${`activity-user-input-resolved-${input.threadId}`}, + ${input.threadId}, + NULL, + 'approval', + 'user-input.resolved', + 'Question answered', + ${JSON.stringify({ requestId: input.requestId, approved: input.approved })}, + 2, + '2026-06-07T00:00:01.000Z' + ) + `.pipe(Effect.mapError(toProviderDoubleError("provider double user input failed"))); + + const completeThread = (threadId: string) => + Effect.gen(function* () { + const active = yield* activeProjectedTurn(threadId); + if (active === null) { + return; + } + yield* upsertTurnState({ threadId, turnId: active.turnId, state: "completed" }); + }); + const providerTurnPort = ProviderTurnPort.of({ ensureTurnStarted: (request) => Effect.gen(function* () { const threadKey = request.threadId as string; - const turnId = TurnId.make(`turn-${threadKey}`); + const activeTurn = yield* activeProjectedTurn(threadKey); + if (activeTurn !== null) { + return { turnId: TurnId.make(activeTurn.turnId) }; + } + + const turnIdString = yield* nextTurnId(threadKey); + const turnId = TurnId.make(turnIdString); yield* Ref.update(calls, (current) => [ ...current, { threadId: threadKey, instruction: request.instruction, + turnId: turnIdString, worktreePath: request.worktreePath, }, ]); - yield* Ref.update(turnStates, (current) => new Map(current).set(threadKey, "running")); + yield* upsertTurnState({ threadId: threadKey, turnId: turnIdString, state: "running" }); yield* appendInstruction(request); if (request.instruction.includes("ASK_PROVIDER_QUESTION")) { - yield* sql` - INSERT INTO projection_pending_approvals ( - request_id, - thread_id, - turn_id, - status, - decision, - created_at, - resolved_at - ) - VALUES ( - ${`request-${threadKey}`}, - ${threadKey}, - ${turnId}, - 'pending', - NULL, - '2026-06-07T00:00:00.000Z', - NULL - ) - `; + yield* insertUserInputRequest(threadKey, turnIdString); + if (request.instruction.includes("DELAY_AFTER_ANSWER")) { + yield* Ref.update(heldAfterAnswerThreads, (current) => { + const next = new Set(current); + next.add(threadKey); + return next; + }); + } return { turnId }; } - yield* Ref.update(turnStates, (current) => new Map(current).set(threadKey, "completed")); + yield* upsertTurnState({ threadId: threadKey, turnId: turnIdString, state: "completed" }); return { turnId }; }).pipe(Effect.mapError(toProviderDoubleError("provider double turn failed"))), }); @@ -155,36 +283,25 @@ const RealPathProviderDoubleLive = Layer.unwrap( respond: (input) => Effect.gen(function* () { yield* Ref.update(responses, (current) => [...current, input]); - yield* Ref.update(turnStates, (current) => - new Map(current).set(input.threadId as string, "completed"), - ); - yield* sql` - UPDATE projection_pending_approvals - SET status = 'resolved', - decision = ${input.approved ? "accept" : "decline"}, - resolved_at = '2026-06-07T00:00:01.000Z' - WHERE request_id = ${input.requestId} - `; + yield* insertUserInputResolved(input); + const threadId = input.threadId as string; + const heldThreads = yield* Ref.get(heldAfterAnswerThreads); + if (!heldThreads.has(threadId)) { + yield* completeThread(threadId); + } }).pipe(Effect.mapError(toProviderDoubleError("provider double response failed"))), }); - const turnProjectionPort = TurnProjectionPort.of({ - getLatestTurnState: (threadId) => - Ref.get(turnStates).pipe( - Effect.map((current) => { - const state = current.get(threadId as string) ?? "running"; - return { - state, - completed: state === "completed", - }; - }), - ), - }); - const tracker = RealPathProviderDouble.of({ calls: Ref.get(calls), + completeThread, reset: Effect.all( - [Ref.set(calls, []), Ref.set(responses, []), Ref.set(turnStates, new Map())], + [ + Ref.set(calls, []), + Ref.set(responses, []), + Ref.set(heldAfterAnswerThreads, new Set()), + Ref.set(turnCounters, new Map()), + ], { discard: true }, ), responses: Ref.get(responses), @@ -193,7 +310,6 @@ const RealPathProviderDoubleLive = Layer.unwrap( return Layer.mergeAll( Layer.succeed(ProviderTurnPort, providerTurnPort), Layer.succeed(ProviderResponsePort, providerResponsePort), - Layer.succeed(TurnProjectionPort, turnProjectionPort), Layer.succeed(RealPathProviderDouble, tracker), ); }), @@ -201,6 +317,8 @@ const RealPathProviderDoubleLive = Layer.unwrap( const TestLayer = Layer.mergeAll(WorkflowRuntimeCoreLive, TicketDiffQueryLive).pipe( Layer.provideMerge(RealPathProviderDoubleLive), + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(WorktreePortLive), Layer.provideMerge(TicketCheckpointServiceLive), Layer.provideMerge(CheckpointStoreLive), @@ -535,11 +653,300 @@ describe.sequential("Workflow runtime real path", () => { assert.equal(calls.length, 2); assert.deepEqual( responses.map((response) => response.responseKind), - ["request"], + ["user-input"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("restarts a dead autonomous agent turn and continues the recovered pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-autonomous-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-autonomous-restart"); + const projectId = ProjectId.make("project-autonomous-restart"); + const ticketId = TicketId.make("ticket-autonomous-restart"); + const pipelineRunId = "pipeline-autonomous-restart" as never; + const stepRunId = "step-autonomous-restart" as never; + const threadId = "thread-autonomous-restart" as never; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + const worktrees = yield* WorktreePort; + const sql = yield* SqlClient.SqlClient; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Autonomous restart board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "first", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover interrupted autonomous step", + }, + { + key: "second", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after recovered step", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Autonomous restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-autonomous-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Autonomous restart ticket", + laneKey: LaneKey.make("implement"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-autonomous-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("implement"), + laneEntryToken: "token-autonomous-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-autonomous-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId, + laneKey: LaneKey.make("implement"), + laneEntryToken: "token-autonomous-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-autonomous-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId, + stepRunId, + stepKey: "first" as never, + stepType: "agent", + }, + }); + + const worktree = yield* worktrees.ensureWorktree(ticketId); + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${threadId}, + 'turn-autonomous-dead', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-autonomous-restart', + ${ticketId}, + ${stepRunId}, + ${threadId}, + 'turn-autonomous-dead', + 'codex', + 'gpt-5.5', + 'recover interrupted autonomous step', + ${worktree.path}, + 'started', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "autonomous restart completion", + ); + if (done === null) { + assert.fail("Expected completed autonomous restart detail"); + } + const calls = yield* provider.calls; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.deepEqual( + calls.map((call) => call.instruction), + ["recover interrupted autonomous step", "continue after recovered step"], ); + assert.isAbove(new Set(calls.map((call) => call.turnId)).size, 1); }).pipe(Effect.provide(TestLayer)), ); + it.effect( + "does not start the next provider-question step before the answered turn completes", + () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-question-race-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-question-race"); + const projectId = ProjectId.make("project-question-race"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Question race board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER", + }, + { + key: "after-answer", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "must wait for answered turn terminal", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Question race board", + repoRoot: repo, + }); + + const ticketId = yield* engine.createTicket({ + boardId, + title: "Question race ticket", + initialLane: LaneKey.make("implement"), + }); + const waiting = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status === "waiting_on_user", + "delayed provider question", + ); + const awaitingStep = waiting?.steps.find((step) => step.status === "awaiting_user"); + assert.isDefined(awaitingStep); + + const firstCalls = yield* provider.calls; + const questionThreadId = firstCalls[0]?.threadId; + assert.isDefined(questionThreadId); + + const resolveFiber = yield* Effect.forkChild( + engine.resolveApproval(awaitingStep?.stepRunId as never, true), + ); + yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status !== "waiting_on_user", + "question answer projection", + ); + yield* TestClock.adjust("250 millis"); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))); + const callsBeforeTerminal = yield* provider.calls; + assert.deepEqual( + callsBeforeTerminal.map((call) => call.instruction), + ["ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER"], + ); + + yield* provider.completeThread(questionThreadId); + yield* Fiber.join(resolveFiber); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "question race completion", + ); + assert.equal(done?.ticket.currentLaneKey, "done"); + const callsAfterTerminal = yield* provider.calls; + assert.deepEqual( + callsAfterTerminal.map((call) => call.instruction), + ["ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER", "must wait for answered turn terminal"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect( "recovery returns promptly for a non-terminal dispatch whose provider session is gone", () => @@ -838,9 +1245,43 @@ describe.sequential("Workflow runtime real path", () => { waitingReason: "Provider is waiting for user input", providerThreadId: threadId, providerRequestId: requestId, - providerResponseKind: "request", + providerResponseKind: "user-input", }, }); + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${threadId}, + 'turn-provider-restart', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; yield* sql` INSERT INTO workflow_dispatch_outbox ( dispatch_id, @@ -870,23 +1311,27 @@ describe.sequential("Workflow runtime real path", () => { ) `; yield* sql` - INSERT INTO projection_pending_approvals ( - request_id, + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, - status, - decision, - created_at, - resolved_at + tone, + kind, + summary, + payload_json, + sequence, + created_at ) VALUES ( - ${requestId}, + 'activity-provider-restart-user-input', ${threadId}, 'turn-provider-restart', - 'pending', - NULL, - '2026-06-07T00:00:04.000Z', - NULL + 'approval', + 'user-input.requested', + 'Provider restart question', + ${`{"requestId":"${requestId}"}`}, + 1, + '2026-06-07T00:00:04.000Z' ) `; @@ -910,6 +1355,10 @@ describe.sequential("Workflow runtime real path", () => { responses.map((response) => response.requestId), [requestId], ); + assert.deepEqual( + responses.map((response) => response.responseKind), + ["user-input"], + ); }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts index da37df1a168..dbc209883f0 100644 --- a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts +++ b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts @@ -51,6 +51,10 @@ export interface ProviderDispatchOutboxShape { dispatchId: DispatchId, threadId: ThreadId, ) => Effect.Effect; + readonly awaitStepTerminal: ( + stepRunId: StepRunId, + threadId: ThreadId, + ) => Effect.Effect; readonly recoverPending: () => Effect.Effect; } diff --git a/apps/server/src/workflow/Services/WorkflowEngine.ts b/apps/server/src/workflow/Services/WorkflowEngine.ts index bb2bd49b95d..08c7d8e14bc 100644 --- a/apps/server/src/workflow/Services/WorkflowEngine.ts +++ b/apps/server/src/workflow/Services/WorkflowEngine.ts @@ -4,6 +4,10 @@ import type * as Effect from "effect/Effect"; import type { WorkflowEventStoreError } from "./Errors.ts"; +export type RecoveredStepResult = + | { readonly _tag: "completed" } + | { readonly _tag: "failed"; readonly error: string }; + export interface WorkflowEngineShape { readonly createTicket: (input: { readonly boardId: BoardId; @@ -20,6 +24,10 @@ export interface WorkflowEngineShape { stepRunId: StepRunId, approved: boolean, ) => Effect.Effect; + readonly completeRecoveredStep: ( + stepRunId: StepRunId, + result: RecoveredStepResult, + ) => Effect.Effect; } export class WorkflowEngine extends Context.Service()( diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts index 6f54723267e..114bc5cba42 100644 --- a/apps/server/src/workflow/WorkflowRuntimeLive.ts +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -28,7 +28,7 @@ import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; export const WorkflowRuntimeCoreLive = Layer.mergeAll( WorkflowEngineLayer, - WorkflowRecoveryLive, + WorkflowRecoveryLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), ).pipe( Layer.provideMerge(RealStepExecutorLive), Layer.provideMerge(ProviderDispatchOutboxLive), From bd1c83c49c1eb583dbceedd2292f53baae4b68fc Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 13:47:42 -0400 Subject: [PATCH 054/295] Expose workflow pipeline orchestration failures Outer pipeline failures should leave a durable blocked-ticket record and an operator-visible warning instead of disappearing behind recovery behavior. The regression now asserts the real event store contains the error detail and the warning is emitted. Constraint: Low-severity polish only; step-level failures continue to use StepFailed routing. Rejected: Routing orchestration failures through step failure handling | There may be no started step when the pipeline wrapper fails. Confidence: high Scope-risk: narrow Directive: Keep the outer pipeline catch interrupt-aware; manual supersede interrupts must not block tickets. Tested: cd apps/server && pnpm exec vp test run src/workflow Not-tested: Full repo typecheck/check will run after the polish pass. --- .../Layers/WorkflowEngine.integration.test.ts | 25 ++++++++++++++++--- .../src/workflow/Layers/WorkflowEngine.ts | 20 ++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index 533eaa5a4f4..8efb72b0a62 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -4,6 +4,8 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Stream from "effect/Stream"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -11,6 +13,7 @@ import { BoardRegistry } from "../Services/BoardRegistry.ts"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; import { ApprovalGateLive } from "./ApprovalGate.ts"; @@ -183,9 +186,15 @@ const pipelineFailureLayer = it.layer( ); pipelineFailureLayer("WorkflowEngine orchestration error handling", (it) => { - it.effect("blocks the ticket when pipeline orchestration fails before the first step", () => - Effect.gen(function* () { + it.effect("blocks and logs when pipeline orchestration fails before the first step", () => { + const messages: string[] = []; + const logger = Logger.make(({ message }) => { + messages.push(String(message)); + }); + + return Effect.gen(function* () { const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; const ticketId = yield* engine.createTicket({ boardId: "b-pipeline-fails" as never, @@ -196,8 +205,16 @@ pipelineFailureLayer("WorkflowEngine orchestration error handling", (it) => { const detail = yield* awaitStatus(ticketId as string, "blocked"); assert.equal(detail?.ticket.status, "blocked"); assert.equal(detail?.steps.length, 0); - }), - ); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const blocked = events.find((event) => event.type === "TicketBlocked"); + assert.include(blocked?.payload.reason ?? "", "definition unavailable"); + assert.isTrue( + messages.some((message) => message.includes("workflow pipeline orchestration failed")), + ); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); }); const approvalDefinition = { diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index d797dfcbb40..14a9b80c2fc 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -380,11 +380,23 @@ const make = Effect.gen(function* () { if (Cause.hasInterruptsOnly(cause)) { return Effect.void; } - return commit({ - type: "TicketBlocked", + const reason = `pipeline error: ${Cause.pretty(cause)}`; + return Effect.logWarning("workflow pipeline orchestration failed", { + boardId, + laneEntryToken, + laneKey: lane.key, + reason, ticketId, - payload: { reason: `pipeline error: ${Cause.pretty(cause)}` }, - }).pipe(Effect.catch(() => Effect.void)); + }).pipe( + Effect.flatMap(() => + commit({ + type: "TicketBlocked", + ticketId, + payload: { reason }, + }), + ), + Effect.catch(() => Effect.void), + ); }), ); From 0a359f3101d3769651c4bd156e613b90c6ea75bf Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 13:48:58 -0400 Subject: [PATCH 055/295] Document recovery continuation interruption limits Recovered provider monitors intentionally fork independently so workflow startup is not held open by provider terminal waits. The comment records that these restart-window continuations are not tracked as live pipeline fibers and therefore cannot be interrupted by manual moves. Constraint: Optional low-risk polish only; do not destabilize passing restart recovery behavior. Rejected: Reworking recovery continuations through live pipeline fiber tracking | That changes concurrency and interruption semantics beyond the requested low-risk pass. Confidence: high Scope-risk: narrow Directive: Revisit this only with dedicated recovery concurrency tests. Tested: cd apps/server && pnpm exec vp test run src/workflow Not-tested: Full repo typecheck/check will run after the polish pass. --- apps/server/src/workflow/Layers/WorkflowRecovery.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts index 301183fe1a1..01e463f7482 100644 --- a/apps/server/src/workflow/Layers/WorkflowRecovery.ts +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -234,6 +234,9 @@ const make = Effect.gen(function* () { yield* completeTerminalPipeline(row, result); yield* releaseTerminalStepLeases; }).pipe( + // Recovery monitors must not block startup. These continuations are not + // registered as live pipeline fibers, so manual moves cannot interrupt + // this narrow restart window. Effect.ignoreCause({ log: true }), Effect.forkDetach({ startImmediately: true }), Effect.asVoid, From b8eea0d649357b86f4dfed0a6f13b5a964cb2379 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 13:49:52 -0400 Subject: [PATCH 056/295] Cover live workflow turn projection mapping The TurnStateReader tests now include one focused path through ProjectionTurnRepositoryLive and TurnProjectionPortLive so running, completed, and error states are covered without the TurnProjectionPort stub seam. Constraint: Optional low-risk test coverage only; no production behavior change. Rejected: Broad real-path workflow expansion | The requested seam is covered by a focused projection test. Confidence: high Scope-risk: narrow Directive: Keep state-mapping tests close to TurnStateReader rather than relying only on workflow runtime scenarios. Tested: pnpm exec vp test run src/workflow/Layers/TurnStateReader.test.ts; cd apps/server && pnpm exec vp test run src/workflow Not-tested: Full repo typecheck/check will run after the polish pass. --- .../workflow/Layers/TurnStateReader.test.ts | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/server/src/workflow/Layers/TurnStateReader.test.ts b/apps/server/src/workflow/Layers/TurnStateReader.test.ts index af0db6c6e1c..fc371692fce 100644 --- a/apps/server/src/workflow/Layers/TurnStateReader.test.ts +++ b/apps/server/src/workflow/Layers/TurnStateReader.test.ts @@ -2,10 +2,12 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { MigrationsLive } from "../../persistence/Migrations.ts"; import { TurnProjectionPort, TurnStateReader } from "../Services/TurnStateReader.ts"; -import { TurnStateReaderLive } from "./TurnStateReader.ts"; +import { TurnProjectionPortLive, TurnStateReaderLive } from "./TurnStateReader.ts"; const stub = (state: string) => Layer.succeed(TurnProjectionPort, { @@ -51,3 +53,50 @@ mk("running")("TurnStateReader running", (it) => { }), ); }); + +const liveProjectionLayer = it.layer( + TurnStateReaderLive.pipe( + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +liveProjectionLayer("TurnStateReader live projection", (it) => { + it.effect("maps running completed and error through the live turn projection", () => + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const reader = yield* TurnStateReader; + const upsert = (threadId: string, turnId: string, state: "running" | "completed" | "error") => + turns.upsertByTurnId({ + threadId: threadId as never, + turnId: turnId as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state, + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: state === "running" ? null : ("2026-06-07T00:00:01.000Z" as never), + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + + yield* upsert("thread-live-running", "turn-live-running", "running"); + yield* upsert("thread-live-completed", "turn-live-completed", "completed"); + yield* upsert("thread-live-error", "turn-live-error", "error"); + + assert.equal((yield* reader.read("thread-live-running" as never))._tag, "running"); + assert.equal((yield* reader.read("thread-live-completed" as never))._tag, "completed"); + const failed = yield* reader.read("thread-live-error" as never); + assert.equal(failed._tag, "failed"); + if (failed._tag === "failed") { + assert.equal(failed.error, "error"); + } + }), + ); +}); From e66578ec4de035f1d22f4438432a0aaddc2c9321 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:22:22 -0400 Subject: [PATCH 057/295] docs: add board-creation UX design spec Make adding a board to a project as easy as a new chat: multiple named file-backed boards listed in the sidebar (board icon), one-click "Add board" that writes a templated .t3/boards/.json defaulting to the user's most recent agent, and server-side discovery/registration of board files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-07-board-creation-ux-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-board-creation-ux-design.md diff --git a/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md b/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md new file mode 100644 index 00000000000..ffd6984ae7b --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md @@ -0,0 +1,123 @@ +# Board Creation UX — Design + +- **Status:** Draft for review +- **Date:** 2026-06-07 +- **Author:** Chris + Claude (brainstorming session) +- **Builds on:** Workflow Boards v1 (`docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md`) + +## 1. Overview + +Make adding a board to a project as easy as starting a new chat. Today a board requires hand-authoring `.t3/boards/.json`, opening a `?boardId=…` URL, and clicking "Register board" (which binds to the *first* project). This feature replaces that with: + +- A project can have **multiple named boards**, listed in the sidebar **alongside its chats** (distinct board icon). +- An **"Add board"** affordance in the project header (next to "New thread") that creates a board in one click. +- The created board uses a **sensible default workflow** whose agent steps default to the user's **most recent agent** (provider instance + model). +- Boards remain **file-backed** (`.t3/boards/.json`) — file is source of truth — but are **auto-discovered and registered** by the server, so they survive restart and hand-authored files appear automatically. + +## 2. Locked decisions + +1. **Storage:** write a real `.t3/boards/.json` file in the project repo (committable, editable, round-trippable by the future v2 visual editor). +2. **Multiplicity:** multiple named boards per project, shown in the sidebar like chats with a board icon (Lucide `SquareKanban`). +3. **Agent default:** resolve `{ instanceId, model }` as **composer sticky selection → most-recent thread's `modelSelection` → first enabled provider's default model**. +4. **Discovery:** server-discovered + file-backed (Approach 1). The server scans `/.t3/boards/*.json`, registers each idempotently, and surfaces the list; `createBoard` writes a templated file then registers it. +5. **Naming:** one-click auto-name (`Workflow board`, `Workflow board 2`, …). Rename = edit the file `name` (watcher re-registers). Inline rename UI is out of scope. + +## 3. Components + +### 3.1 Board discovery & registration (server) + +A `BoardDiscovery` service that, given a project (`projectId`, `repoRoot`): +- Lists `/.t3/boards/*.json`. +- For each file: read → `Schema.decodeUnknown(WorkflowDefinition)` → `lintWorkflowDefinition` (with the real provider-exists / instruction-file-exists checks from `WorkflowFileLoader`). +- Registers each into `projection_board` + `BoardRegistry` under a **deterministic boardId** = `BoardId.make(\`${projectId}__${slug}\`)` where `slug` is the file basename without extension. Deterministic ids make re-scan idempotent (same file → same board row). +- A lint/parse failure does **not** drop the board: it is surfaced as a board entry with an `error` field so the UI can show it as broken rather than silently missing. + +**When it runs:** (a) on project resolution / when a project becomes visible, and (b) via a file-watcher on each project's `.t3/boards/` directory (mirroring the `serverSettings.ts` watcher: debounce + reload). The watcher emits a board-list-changed signal so the sidebar updates live. + +### 3.2 RPC surface (contracts + ws.ts) + +Two new `workflow.*` methods (mirroring existing handler idioms in `ws.ts`, scoped `AuthWorkflowReadScope` / `AuthWorkflowOperateScope`): + +- `workflow.listBoards({ projectId })` → `ReadonlyArray` where `BoardListEntry = { boardId, name, filePath, error: string | null }`. Triggers discovery for that project if not yet scanned, then returns the registered boards. +- `workflow.createBoard({ projectId, repoRoot, name, agent: { instance, model } })` → `{ boardId, snapshot: BoardSnapshot }`. Server-side: pick a unique slug, write the templated file, register it, return the board id + initial snapshot. + +A board-list change push (optional but recommended): extend the board subscription / add `workflow.subscribeBoards({ projectId })` streaming snapshot+deltas so the sidebar reflects file-watch changes without polling. (If kept lean for v1: `listBoards` is re-fetched after `createBoard` and on a lightweight `boardsChanged` push.) + +### 3.3 Create-board flow (server) + +`createBoard` steps: +1. Resolve a unique slug from the requested `name` (slugify → `workflow-board`; on collision append `-2`, `-3`, …, checking existing files). +2. Generate the **default definition** via `defaultBoardDefinition({ name, agent })` (§3.4). +3. Write `/.t3/boards/.json` (pretty-printed JSON). Fail with a typed error if the file already exists unexpectedly or the write fails. +4. Register via the existing loader path (`WorkflowFileLoader.loadAndRegister`), which lints (the passed agent instance must be a configured provider, which it is) and computes the version hash. +5. Return `{ boardId, snapshot }`. + +### 3.4 Default board template + +`defaultBoardDefinition({ name, agent })` returns: + +```jsonc +{ + "name": "", + "settings": { "maxConcurrentTickets": 3 }, + "lanes": [ + { "key": "backlog", "name": "Backlog", "entry": "manual" }, + { "key": "implement", "name": "Implement", "entry": "auto", + "pipeline": [ + { "key": "code", "type": "agent", + "agent": { "instance": "", "model": "" }, + "instruction": "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence." }, + { "key": "review", "type": "agent", + "agent": { "instance": "", "model": "" }, + "instruction": "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship." } + ], + "on": { "success": "owner_review", "failure": "needs_attention", "blocked": "needs_attention" } }, + { "key": "owner_review", "name": "Owner Review", "entry": "manual" }, + { "key": "needs_attention", "name": "Needs Attention", "entry": "manual" }, + { "key": "done", "name": "Done", "entry": "manual", "terminal": true } + ] +} +``` + +Both agent steps use the resolved recent agent. (Same shape as the shipped `delivery.json`, parameterized.) + +### 3.5 Agent resolution (web) + +A `resolveRecentAgent()` helper returning `{ instance: ProviderInstanceId, model: string } | null`: +1. **Composer sticky:** `composerDraftStore` `stickyActiveProvider` + `stickyModelSelectionByProvider[stickyActiveProvider]` → `{ instanceId, model }`. +2. **Most-recent thread:** the most recently updated thread's `modelSelection` (`{ instanceId, model }`). +3. **Default:** first enabled+installed provider from `useServerConfig().providers`, with its default model (`DEFAULT_MODEL_BY_PROVIDER[driver]` or the instance's first model). +Returns `null` only if no providers are configured (then "Add board" is disabled with a tooltip). + +### 3.6 Sidebar integration + route (web) + +- **Sidebar:** in `SidebarProjectItem` (`apps/web/src/components/Sidebar.tsx`), render the project's boards (from a board-list store slice fed by `listBoards` + the boardsChanged push) as entries **like threads**, using the `SquareKanban` icon, each linking to the board route for its `boardId`. +- **Add board:** a button next to the existing "New thread" `SquarePenIcon` in the project header → `resolveRecentAgent()` → `workflow.createBoard({ projectId, repoRoot, name: nextDefaultName(existing), agent })` → navigate to the new board. +- **Board route** (`_chat.$environmentId.board.tsx`): take the **real `boardId`** (from the sidebar nav), drop the manual **"Register board"** button and the `projects[0]` registration assumption. The board's project is known from `projection_board`. `subscribeBoard(boardId)` and ticket flows are unchanged. + +## 4. Error handling + +- **No providers configured:** `resolveRecentAgent()` returns null → "Add board" disabled with an explanatory tooltip. +- **Lint/parse failure on a discovered file:** surfaced as a `BoardListEntry` with `error`; the sidebar shows it as broken (e.g., a warning state), not hidden. +- **Slug collision / existing file:** uniquifier appends a numeric suffix; if the chosen file already exists, fail with a typed error rather than overwriting. +- **File write failure:** typed error returned to the client; the board is not registered. +- **Stale boardId in a URL** (file deleted): the route shows "Board not found" (existing "No board selected" path generalized). + +## 5. Testing + +**Server** +- Discovery registers every `.t3/boards/*.json` for a project with **stable boardIds across re-scan** (idempotent); a malformed file yields an `error` entry, not a dropped board. +- `createBoard` writes a valid, lint-passing file containing the passed agent instance+model, registers it, and returns a usable snapshot; slug uniquifier avoids collisions. +- `listBoards` returns the discovered + created boards for the project. +- `defaultBoardDefinition` output decodes via `WorkflowDefinition` and passes `lintWorkflowDefinition` for a valid agent. + +**Web** +- `resolveRecentAgent` precedence: sticky → recent-thread → default; returns null with no providers. +- "Add board" calls `createBoard` with the resolved agent and navigates to the returned board. +- Sidebar renders board entries with the board icon and links to the correct `boardId`. + +## 6. Open questions + +- **Live board-list updates:** `workflow.subscribeBoards` stream vs. re-fetch `listBoards` on a `boardsChanged` push. Recommend the lighter push+refetch for v1; confirm during planning against how the sidebar currently subscribes. +- **Where discovery is triggered:** confirm the exact project-resolution hook in the server runtime to attach scan + watcher (mirror `serverSettings.ts`). +- **`.t3/boards/` gitignore:** boards are committable by design; no ignore. Confirm this matches expectations (a generated board file will show up in `git status`). From 5067186d8f76f55f5dac5850d7898d04a4079031 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:29:57 -0400 Subject: [PATCH 058/295] docs: fold GPT-5.5 review into board-creation UX spec Server-resolved workspace root (drop client repoRoot/filePath; remove trust-prone registerBoardFromFile); board list as a separate source from the registry (invalid files surface as error entries); net-new per-project board-list store slice; grouped-project member picker for "Add board"; modelSelection from full threads with availability-filtered providers; loader path split; file-deletion unregister; exclusive-create writes; BoardSnapshot.projectId; full RPC layer wiring enumerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-07-board-creation-ux-design.md | 69 ++++++++++++------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md b/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md index ffd6984ae7b..bad82c408f2 100644 --- a/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md +++ b/docs/superpowers/specs/2026-06-07-board-creation-ux-design.md @@ -24,33 +24,43 @@ Make adding a board to a project as easy as starting a new chat. Today a board r ## 3. Components -### 3.1 Board discovery & registration (server) +### 3.1 Board discovery (server) -A `BoardDiscovery` service that, given a project (`projectId`, `repoRoot`): -- Lists `/.t3/boards/*.json`. -- For each file: read → `Schema.decodeUnknown(WorkflowDefinition)` → `lintWorkflowDefinition` (with the real provider-exists / instruction-file-exists checks from `WorkflowFileLoader`). -- Registers each into `projection_board` + `BoardRegistry` under a **deterministic boardId** = `BoardId.make(\`${projectId}__${slug}\`)` where `slug` is the file basename without extension. Deterministic ids make re-scan idempotent (same file → same board row). -- A lint/parse failure does **not** drop the board: it is surfaced as a board entry with an `error` field so the UI can show it as broken rather than silently missing. +A `BoardDiscovery` service that, given a `projectId`, **resolves the workspace root server-side** (via `ProjectionSnapshotQuery.getProjectShellById` / `ProjectionProjectRepository` — never trusting a client-supplied path) and scans `/.t3/boards/*.json`. For each file it produces a **`BoardListEntry`** = `{ boardId, name, filePath (workspace-relative), error: string | null }`: +- `boardId` is **deterministic** = `BoardId.make(\`${projectId}__${slug}\`)`, `slug` = file basename. Re-scan → same id. +- For a **valid** file (decodes via `WorkflowDefinition` + passes `lintWorkflowDefinition`): call `WorkflowFileLoader.loadAndRegister` (registers into `BoardRegistry` + upserts `projection_board`) and emit a `BoardListEntry` with `error: null`. +- For an **invalid** file: do **not** register (registry/`projection_board` hold only valid definitions). Emit a `BoardListEntry` with `error` set so the UI shows it as broken. -**When it runs:** (a) on project resolution / when a project becomes visible, and (b) via a file-watcher on each project's `.t3/boards/` directory (mirroring the `serverSettings.ts` watcher: debounce + reload). The watcher emits a board-list-changed signal so the sidebar updates live. +The **board list is a separate source from the registry**: `BoardDiscovery` owns the list (valid + invalid); `BoardRegistry`/`projection_board` hold only valid, registered boards. The list is held in a `BoardDiscovery` projection/cache keyed by `projectId`. -### 3.2 RPC surface (contracts + ws.ts) +**Deletion:** if a previously-seen file disappears on re-scan, `BoardDiscovery` drops its list entry and **unregisters** it (new `BoardRegistry.unregister(boardId)` + `projection_board` delete) so stale boards don't linger. -Two new `workflow.*` methods (mirroring existing handler idioms in `ws.ts`, scoped `AuthWorkflowReadScope` / `AuthWorkflowOperateScope`): +**When it runs:** (a) on project resolution / when a project becomes visible, and (b) via a file-watcher on each project's `.t3/boards/` directory (mirroring the `serverSettings.ts` watcher: debounce + reload). The watcher emits a `boardsChanged({ projectId })` signal so the sidebar updates live. -- `workflow.listBoards({ projectId })` → `ReadonlyArray` where `BoardListEntry = { boardId, name, filePath, error: string | null }`. Triggers discovery for that project if not yet scanned, then returns the registered boards. -- `workflow.createBoard({ projectId, repoRoot, name, agent: { instance, model } })` → `{ boardId, snapshot: BoardSnapshot }`. Server-side: pick a unique slug, write the templated file, register it, return the board id + initial snapshot. +### 3.2 RPC surface (contracts + ws.ts + client) -A board-list change push (optional but recommended): extend the board subscription / add `workflow.subscribeBoards({ projectId })` streaming snapshot+deltas so the sidebar reflects file-watch changes without polling. (If kept lean for v1: `listBoards` is re-fetched after `createBoard` and on a lightweight `boardsChanged` push.) +Two new `workflow.*` methods. **Neither accepts a path from the client** — the server resolves the workspace root from `projectId`. Wiring touches all the layers the existing `workflow.*` methods use: `WORKFLOW_WS_METHODS` (`contracts/src/workflow.ts`), `Rpc.make` + `WsRpcGroup` (`contracts/src/rpc.ts`), `RPC_REQUIRED_SCOPE` (`ws.ts`), the handler map (`WorkflowRpcHandlers.ts`), the client runtime (`client-runtime/src/wsRpcClient.ts`), and `EnvironmentApi` (`apps/web/src/environmentApi.ts`). + +- `workflow.listBoards({ projectId })` → `ReadonlyArray` (`{ boardId, name, filePath, error }`), scope `AuthWorkflowReadScope`. Triggers discovery if not yet scanned, returns valid + invalid entries. +- `workflow.createBoard({ projectId, name, agent: { instance, model } })` → `{ boardId, snapshot: BoardSnapshot }`, scope `AuthWorkflowOperateScope`. Server resolves the workspace root from `projectId`, picks a unique slug, exclusively writes the templated file, registers it, returns id + snapshot. + +**Security:** the existing `workflow.registerBoardFromFile` (which trusts client `filePath`/`repoRoot`) is **removed** — discovery + `createBoard` are the only registration paths, and both resolve the root server-side. This also closes the pre-existing v1 path-trust hole. + +**Live updates:** a lightweight `boardsChanged({ projectId })` push (emitted by the file-watcher and after `createBoard`) tells the client to re-fetch `listBoards`. (A full `subscribeBoards` stream is a possible later refinement; push+refetch is sufficient for v1.) + +`BoardSnapshot` gains a `projectId` field (the board row already carries it; just expose it) so the route knows its project without `projects[0]`. ### 3.3 Create-board flow (server) `createBoard` steps: -1. Resolve a unique slug from the requested `name` (slugify → `workflow-board`; on collision append `-2`, `-3`, …, checking existing files). -2. Generate the **default definition** via `defaultBoardDefinition({ name, agent })` (§3.4). -3. Write `/.t3/boards/.json` (pretty-printed JSON). Fail with a typed error if the file already exists unexpectedly or the write fails. -4. Register via the existing loader path (`WorkflowFileLoader.loadAndRegister`), which lints (the passed agent instance must be a configured provider, which it is) and computes the version hash. -5. Return `{ boardId, snapshot }`. +1. **Resolve `workspaceRoot` from `projectId`** server-side (`ProjectionSnapshotQuery`/`ProjectionProjectRepository`); reject if the project is unknown. +2. Resolve a unique slug from `name` (slugify → `workflow-board`; on collision append `-2`, `-3`, … by checking existing `.t3/boards/*.json`). +3. Generate the **default definition** via `defaultBoardDefinition({ name, agent })` (§3.4). +4. **Exclusively create** `/.t3/boards/.json` (pretty JSON) — fail (don't overwrite) if the path already exists, to avoid slug races. (`WorkspaceFileSystem.writeFile` currently overwrites + creates parents; add an exclusive-create variant or pre-check-under-lock.) +5. Register via `WorkflowFileLoader.loadAndRegister` (lints; the passed agent instance is a configured provider, so it passes; computes the version hash). +6. Return `{ boardId, snapshot }`. + +**Loader path fix:** `WorkflowFileLoader` currently persists the same absolute `filePath` it reads. Split this: the loader takes `workspaceRoot` + a **workspace-relative** board path, reads the absolute join, and persists the workspace-relative path (so multi-project boards and the UI display path are correct). ### 3.4 Default board template @@ -85,15 +95,17 @@ Both agent steps use the resolved recent agent. (Same shape as the shipped `deli A `resolveRecentAgent()` helper returning `{ instance: ProviderInstanceId, model: string } | null`: 1. **Composer sticky:** `composerDraftStore` `stickyActiveProvider` + `stickyModelSelectionByProvider[stickyActiveProvider]` → `{ instanceId, model }`. -2. **Most-recent thread:** the most recently updated thread's `modelSelection` (`{ instanceId, model }`). -3. **Default:** first enabled+installed provider from `useServerConfig().providers`, with its default model (`DEFAULT_MODEL_BY_PROVIDER[driver]` or the instance's first model). -Returns `null` only if no providers are configured (then "Add board" is disabled with a tooltip). +2. **Most-recent thread:** the most recently updated thread's `modelSelection`. **Note:** `SidebarThreadSummary` does *not* carry `modelSelection`; read it from full threads / thread shells (`apps/web/src/types.ts` `Thread.modelSelection`), not sidebar summaries. +3. **Default:** the first **enabled + installed + available** provider from `useServerConfig().providers`, filtered via the existing `apps/web/src/providerInstances.ts` helpers (not `providers[0]` blindly), with its default model (`DEFAULT_MODEL_BY_PROVIDER[driver]` or the instance's first model). + +Each candidate is validated against the currently-available provider instances before use (a stale sticky/thread instance that no longer exists falls through to the next source). Returns `null` only if no usable provider exists (then "Add board" is disabled with a tooltip). ### 3.6 Sidebar integration + route (web) -- **Sidebar:** in `SidebarProjectItem` (`apps/web/src/components/Sidebar.tsx`), render the project's boards (from a board-list store slice fed by `listBoards` + the boardsChanged push) as entries **like threads**, using the `SquareKanban` icon, each linking to the board route for its `boardId`. -- **Add board:** a button next to the existing "New thread" `SquarePenIcon` in the project header → `resolveRecentAgent()` → `workflow.createBoard({ projectId, repoRoot, name: nextDefaultName(existing), agent })` → navigate to the new board. -- **Board route** (`_chat.$environmentId.board.tsx`): take the **real `boardId`** (from the sidebar nav), drop the manual **"Register board"** button and the `projects[0]` registration assumption. The board's project is known from `projection_board`. `subscribeBoard(boardId)` and ticket flows are unchanged. +- **New board-list store slice (net-new):** there is no existing per-project board-list data path (the store only tracks one active board's snapshot via `boardStateById`/`applyBoardStreamItem`). Add an environment/project-scoped board-list slice fed by `workflow.listBoards` and refreshed on the `boardsChanged` push. +- **Sidebar:** in `SidebarProjectItem` (`apps/web/src/components/Sidebar.tsx`), render the project's boards from that slice as **separate sub-items** (the project row's single hover right-slot is occupied by the new-thread button + remote badge — boards are rows, not crammed into that slot), using the `SquareKanban` icon, each linking to the board route for its `boardId`. +- **Add board:** a button in the project header that **mirrors "New thread"** — for a grouped project (`memberProjects.length > 1`) it first prompts for the concrete member project (same `contextMenu` pattern as `handleCreateThreadClick`), then `resolveRecentAgent()` → `workflow.createBoard({ projectId, name: nextDefaultName(existing), agent })` → navigate to the new board. +- **Board route** (`_chat.$environmentId.board.tsx`): take the **real `boardId`** (from the sidebar nav), drop the manual **"Register board"** button and the `projects[0]` assumption. The board's project comes from `BoardSnapshot.projectId` (§3.2). A `boardId` whose file was deleted renders an explicit **"Board not found"** state. `subscribeBoard(boardId)` and ticket flows are unchanged (they operate by `boardId`/`ticketId`). ## 4. Error handling @@ -118,6 +130,11 @@ Returns `null` only if no providers are configured (then "Add board" is disabled ## 6. Open questions -- **Live board-list updates:** `workflow.subscribeBoards` stream vs. re-fetch `listBoards` on a `boardsChanged` push. Recommend the lighter push+refetch for v1; confirm during planning against how the sidebar currently subscribes. -- **Where discovery is triggered:** confirm the exact project-resolution hook in the server runtime to attach scan + watcher (mirror `serverSettings.ts`). -- **`.t3/boards/` gitignore:** boards are committable by design; no ignore. Confirm this matches expectations (a generated board file will show up in `git status`). +- **Where discovery is triggered:** confirm the exact project-resolution hook in the server runtime to attach scan + watcher (mirror `serverSettings.ts`). Discovery must resolve `workspaceRoot` from `projectId` via `ProjectionSnapshotQuery`/`ProjectionProjectRepository` — confirm which is the clean read API. +- **Exclusive-create write:** `WorkspaceFileSystem.writeFile` overwrites; confirm the cleanest way to add an exclusive-create (e.g. `wx` flag) or a per-project create lock for the slug race. +- **Removing `registerBoardFromFile`:** confirm no other caller depends on it before deleting (the board route is the only known consumer); otherwise internalize it. +- **`.t3/boards/` gitignore:** boards are committable by design; no ignore. Confirm a generated board file showing in `git status` is expected. + +## 7. Changes from review (incorporated) + +Folded GPT-5.5's code-grounded review: server-resolved workspace root (drop client `repoRoot`/`filePath`; remove the trust-prone `registerBoardFromFile`); board **list** as a separate source from the **registry** (invalid files surface as `error` entries, valid ones register); net-new per-project board-list store slice; "Add board" mirrors the grouped-project member picker; `modelSelection` read from full threads (not sidebar summaries) with availability-filtered providers; loader path split (read-abs vs persist-relative); file-deletion unregister; exclusive-create writes; `BoardSnapshot.projectId` added; full RPC layer wiring enumerated. From f7ffbea6386ba2c668209c100978b24c6399e3f2 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:34:05 -0400 Subject: [PATCH 059/295] docs: add board-creation UX implementation plan 14 TDD tasks: default template + slug helpers, BoardListEntry/createBoard contracts, registry unregister + read-model list/delete, loader path split, project workspace resolver, BoardDiscovery, listBoards/createBoard handlers (server-resolved root; registerBoardFromFile removed), runtime+watcher wiring, client RPC wiring, resolveRecentAgent, board-list store slice, sidebar board rows + Add board, board route cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-07-board-creation-ux.md | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-board-creation-ux.md diff --git a/docs/superpowers/plans/2026-06-07-board-creation-ux.md b/docs/superpowers/plans/2026-06-07-board-creation-ux.md new file mode 100644 index 00000000000..d0cf28c1a9f --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-board-creation-ux.md @@ -0,0 +1,431 @@ +# Board Creation UX — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user add a board to a project in one click — multiple named, file-backed boards listed in the sidebar like chats (board icon), defaulting to the user's most recent agent. + +**Architecture:** Server-side `BoardDiscovery` scans `/.t3/boards/*.json` per project (root resolved from `projectId`, never the client), registering valid files and surfacing invalid ones as error list-entries. A new `workflow.createBoard` RPC writes a templated default board (recent agent baked in) and registers it. The web app gains a per-project board-list store slice, a `resolveRecentAgent` helper, sidebar board rows + an "Add board" affordance, and a cleaned-up board route that uses the real `boardId`. + +**Tech Stack:** TypeScript, Effect 4 (beta), `effect/unstable/sql`, `@effect/vitest`; React 19, Zustand, TanStack Router, `vite-plus/test`. + +**Spec:** `docs/superpowers/specs/2026-06-07-board-creation-ux-design.md`. **Builds on** Workflow Boards v1 (`apps/server/src/workflow/**`, `packages/contracts/src/workflow.ts`, web board route + components). + +--- + +## Conventions +- Server: interfaces in `…/Services/Name.ts`, impls + `Layer.effect` in `…/Layers/Name.ts`. Tests colocated `Name.test.ts`. +- Run server tests: `cd apps/server && pnpm exec vp test run src/`. Contracts: `pnpm --filter @t3tools/contracts test`. Web: `cd apps/web && pnpm exec vp test run src/`. +- Gates before finishing: `pnpm exec vp run typecheck`, `pnpm exec vp check` (fix with `pnpm exec vp fmt `). +- Commit after every task with the given message. Stay on branch `ft/hyperion`. +- **Mirror existing patterns**: the v1 workflow code is the reference for Effect services, SQL, RPC wiring, and tests. + +## File Structure +**Create (server):** `workflow/defaultBoard.ts`, `workflow/boardSlug.ts`, `workflow/Services/BoardDiscovery.ts`, `workflow/Layers/BoardDiscovery.ts`, `workflow/Services/ProjectWorkspaceResolver.ts` (+ `Layers/`), `workflow/Layers/CreateBoard.ts` (or fold into handlers). Tests colocated. +**Modify (server):** `workflow/Services/BoardRegistry.ts` + `Layers/BoardRegistry.ts` (add `unregister`), `workflow/Layers/WorkflowReadModel.ts` (board delete + `listBoardsForProject`), `workflow/Layers/WorkflowFileLoader.ts` (path split), `workflow/Layers/WorkflowRpcHandlers.ts` (+listBoards/createBoard, −registerBoardFromFile), `ws.ts` (scopes), `WorkflowRuntimeLive.ts` (provide new layers + discovery trigger). +**Modify (contracts):** `workflow.ts` (`BoardListEntry`, `BoardSnapshot.projectId`, `WORKFLOW_WS_METHODS`), `rpc.ts` (`Rpc.make` + `WsRpcGroup`). +**Create (web):** `apps/web/src/workflow/resolveRecentAgent.ts`, `apps/web/src/workflow/boardListState.ts`. **Modify (web):** `store.ts` (board-list slice), `workflow/boardRpc.ts`, `components/Sidebar.tsx`, `routes/_chat.$environmentId.board.tsx`, `components/board/BoardHeaderControls.tsx`, `environmentApi.ts`, `packages/client-runtime/src/wsRpcClient.ts`. + +--- + +## Task 1: Default board template (pure) + +**Files:** Create `apps/server/src/workflow/defaultBoard.ts` + `defaultBoard.test.ts`. + +- [ ] **Step 1: Failing test** +```typescript +// apps/server/src/workflow/defaultBoard.test.ts +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import { defaultBoardDefinition } from "./defaultBoard.ts"; +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +describe("defaultBoardDefinition", () => { + const def = defaultBoardDefinition({ name: "My board", agent: { instance: "codex", model: "gpt-5.4" } }); + it("decodes as a valid WorkflowDefinition", () => + Effect.gen(function* () { + const decoded = yield* Schema.decodeUnknown(WorkflowDefinition)(def); + assert.equal(decoded.name, "My board"); + assert.equal(decoded.lanes.find((l) => l.key === "implement")?.pipeline?.length, 2); + }).pipe(Effect.runPromise)); + it("passes the linter for a known agent instance", () => { + const errors = lintWorkflowDefinition(def as never, { + providerInstanceExists: (id) => id === "codex", + instructionFileExists: () => true, + }); + assert.deepEqual(errors, []); + }); + it("bakes the agent into both steps", () => { + const steps = (def.lanes.find((l) => l.key === "implement")?.pipeline ?? []) as ReadonlyArray<{ + agent?: { instance: string; model: string }; + }>; + assert.equal(steps[0]?.agent?.instance, "codex"); + assert.equal(steps[1]?.agent?.model, "gpt-5.4"); + }); +}); +``` +- [ ] **Step 2: Run → FAIL.** `cd apps/server && pnpm exec vp test run src/workflow/defaultBoard.test.ts` +- [ ] **Step 3: Implement** +```typescript +// apps/server/src/workflow/defaultBoard.ts +import type { WorkflowDefinition } from "@t3tools/contracts"; + +export interface DefaultBoardAgent { + readonly instance: string; + readonly model: string; +} + +export const defaultBoardDefinition = (input: { + readonly name: string; + readonly agent: DefaultBoardAgent; +}): WorkflowDefinition => + ({ + name: input.name, + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: input.agent.instance, model: input.agent.model }, + instruction: + "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence.", + }, + { + key: "review", + type: "agent", + agent: { instance: input.agent.instance, model: input.agent.model }, + instruction: + "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship.", + }, + ], + on: { success: "owner_review", failure: "needs_attention", blocked: "needs_attention" }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "needs_attention", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }) as WorkflowDefinition; +``` +- [ ] **Step 4: Run → PASS.** +- [ ] **Step 5: Commit** `git add apps/server/src/workflow/defaultBoard.ts apps/server/src/workflow/defaultBoard.test.ts && git commit -m "feat(workflow): default board template"` + +--- + +## Task 2: Board slug helpers (pure) + +**Files:** Create `apps/server/src/workflow/boardSlug.ts` + `boardSlug.test.ts`. + +- [ ] **Step 1: Failing test** +```typescript +// apps/server/src/workflow/boardSlug.test.ts +import { assert, describe, it } from "@effect/vitest"; +import { slugifyBoardName, uniqueBoardSlug } from "./boardSlug.ts"; + +describe("boardSlug", () => { + it("slugifies names", () => { + assert.equal(slugifyBoardName("Workflow Board"), "workflow-board"); + assert.equal(slugifyBoardName(" A/B board!! "), "a-b-board"); + assert.equal(slugifyBoardName("!!!"), "board"); // fallback + }); + it("uniquifies against existing slugs", () => { + const existing = new Set(["workflow-board", "workflow-board-2"]); + assert.equal(uniqueBoardSlug("workflow-board", existing), "workflow-board-3"); + assert.equal(uniqueBoardSlug("fresh", existing), "fresh"); + }); +}); +``` +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** +```typescript +// apps/server/src/workflow/boardSlug.ts +export const slugifyBoardName = (name: string): string => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "board"; +}; + +export const uniqueBoardSlug = (base: string, existing: ReadonlySet): string => { + if (!existing.has(base)) return base; + let n = 2; + while (existing.has(`${base}-${n}`)) n += 1; + return `${base}-${n}`; +}; +``` +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): board slug helpers"` + +--- + +## Task 3: Contracts — BoardListEntry, BoardSnapshot.projectId, RPC methods + +**Files:** Modify `packages/contracts/src/workflow.ts`, `packages/contracts/src/rpc.ts`. Test: `packages/contracts/src/workflow.test.ts`. + +- [ ] **Step 1: Failing test** (append) +```typescript +// packages/contracts/src/workflow.test.ts (append) +import { BoardListEntry, BoardSnapshot, WORKFLOW_WS_METHODS } from "./workflow.ts"; + +describe("board creation contracts", () => { + it("decodes a BoardListEntry", () => + Effect.gen(function* () { + const e = yield* Schema.decodeUnknown(BoardListEntry)({ + boardId: "p1__board", name: "Board", filePath: ".t3/boards/board.json", error: null, + }); + assert.equal(e.error, null); + }).pipe(Effect.runPromise)); + it("BoardSnapshot carries projectId", () => { + assert.isTrue(Object.keys(BoardSnapshot.fields).includes("projectId")); + }); + it("exposes the new methods", () => { + assert.equal(WORKFLOW_WS_METHODS.listBoards, "workflow.listBoards"); + assert.equal(WORKFLOW_WS_METHODS.createBoard, "workflow.createBoard"); + }); +}); +``` +- [ ] **Step 2: Run → FAIL.** `pnpm --filter @t3tools/contracts test -- workflow.test.ts` +- [ ] **Step 3: Implement** in `packages/contracts/src/workflow.ts`: + - Add `ProjectId` import if not present. + - Add `listBoards` + `createBoard` to `WORKFLOW_WS_METHODS`. + - Add `projectId: ProjectId` to the `BoardSnapshot.board` struct (or top-level `BoardSnapshot`, matching where it's defined). + - Add: +```typescript +export const BoardListEntry = Schema.Struct({ + boardId: BoardId, + name: Schema.String, + filePath: Schema.String, + error: Schema.NullOr(Schema.String), +}); +export type BoardListEntry = typeof BoardListEntry.Type; +``` +- [ ] **Step 4:** In `packages/contracts/src/rpc.ts` add `Rpc.make` defs + register in the same `WsRpcGroup` as the other `workflow.*` methods (mirror `WsWorkflowGetTicketDiffRpc`): +```typescript +export const WsWorkflowListBoardsRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoards, { + payload: Schema.Struct({ projectId: ProjectId }), + success: Schema.Array(BoardListEntry), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); +export const WsWorkflowCreateBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.createBoard, { + payload: Schema.Struct({ + projectId: ProjectId, + name: Schema.String, + agent: Schema.Struct({ instance: TrimmedNonEmptyString, model: TrimmedNonEmptyString }), + }), + success: Schema.Struct({ boardId: BoardId, snapshot: BoardSnapshot }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); +``` + Remove `WsWorkflowRegisterBoardFromFileRpc` and its `WORKFLOW_WS_METHODS.registerBoardFromFile` entry (and any group registration). +- [ ] **Step 5: Run → PASS. Step 6: Commit** `git add packages/contracts/src/workflow.ts packages/contracts/src/rpc.ts packages/contracts/src/workflow.test.ts && git commit -m "feat(workflow): board list/create RPC contracts; drop registerBoardFromFile"` + +--- + +## Task 4: BoardRegistry.unregister + WorkflowReadModel board delete/list + +**Files:** Modify `workflow/Services/BoardRegistry.ts` + `Layers/BoardRegistry.ts` + test; `workflow/Services/WorkflowReadModel.ts` + `Layers/WorkflowReadModel.ts` + test. + +- [ ] **Step 1: Failing tests** +```typescript +// append to apps/server/src/workflow/Layers/BoardRegistry.test.ts +it.effect("unregister removes a registered definition", () => + Effect.gen(function* () { + const reg = yield* BoardRegistry; + yield* reg.register("b-x" as never, def); // `def` from existing test scope + yield* reg.unregister("b-x" as never); + assert.isNull(yield* reg.getDefinition("b-x" as never)); + }), +); +``` +```typescript +// append to apps/server/src/workflow/Layers/WorkflowReadModel.test.ts +it.effect("lists boards for a project and deletes one", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ boardId: "p1__a" as never, projectId: "p1" as never, name: "A", + workflowFilePath: ".t3/boards/a.json", workflowVersionHash: "h", maxConcurrentTickets: 3 }); + const before = yield* read.listBoardsForProject("p1" as never); + assert.equal(before.length, 1); + yield* read.deleteBoard("p1__a" as never); + assert.equal((yield* read.listBoardsForProject("p1" as never)).length, 0); + }), +); +``` +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** `unregister` in BoardRegistry (mirror `register`, `Ref.update` deleting the key); add `deleteBoard(boardId)` (`DELETE FROM projection_board WHERE board_id = ?`) and `listBoardsForProject(projectId)` (`SELECT board_id, name, workflow_file_path … WHERE project_id = ?`) to `WorkflowReadModel`, with matching interface methods. Return rows as `{ boardId, name, filePath }`. +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): board unregister + read-model board delete/list"` + +--- + +## Task 5: WorkflowFileLoader path split (read-absolute, persist-relative) + +**Files:** Modify `workflow/Services/WorkflowFileLoader.ts` + `Layers/WorkflowFileLoader.ts` + test. + +Currently `loadAndRegister({ boardId, projectId, filePath, repoRoot })` reads and persists the same `filePath`. Change it to read `path.resolve(repoRoot, relativePath)` but persist `relativePath`. + +- [ ] **Step 1: Failing test** — assert that after `loadAndRegister({ …, workspaceRoot: tmp, relativePath: ".t3/boards/x.json" })`, the registered board's `workflowFilePath` is the **relative** path, while the file is read from the absolute join. (Use a temp dir with a valid board file; stub provider-exists to accept the agent.) +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** — change the input to `{ boardId, projectId, workspaceRoot, relativePath, agentInstanceExists? }`; read `path.resolve(workspaceRoot, relativePath)`; pass `relativePath` to `WorkflowReadModel.registerBoard({ workflowFilePath: relativePath, … })`. Update the one existing caller (none in prod yet besides handlers being rewritten in Task 8). +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "refactor(workflow): split loader read path from persisted relative path"` + +--- + +## Task 6: ProjectWorkspaceResolver (resolve workspaceRoot from projectId) + +**Files:** Create `workflow/Services/ProjectWorkspaceResolver.ts` + `Layers/ProjectWorkspaceResolver.ts` + test. A thin port over the orchestration project projection so it's stubbable. + +- [ ] **Step 1: Failing test** — stub the underlying project query; assert `resolve(projectId)` returns the project's `workspaceRoot`, and fails with a typed error for an unknown project. +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** interface `{ resolve: (projectId) => Effect }`. `*Live` reads `ProjectionSnapshotQuery.getProjectShellById(projectId)` (or `ProjectionProjectRepository`) → `workspaceRoot`; confirm the exact method/field from `apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts`. Map a missing project to a typed error. +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): project workspace-root resolver"` + +--- + +## Task 7: BoardDiscovery service + +**Files:** Create `workflow/Services/BoardDiscovery.ts` + `Layers/BoardDiscovery.ts` + test. + +Scans `/.t3/boards/*.json` for a project, classifies valid/invalid, registers valid via the loader, unregisters deleted, caches the per-project `BoardListEntry[]`. + +- [ ] **Step 1: Failing test** (real temp dir + FileSystem; stub `ProjectWorkspaceResolver` to return the temp dir; stub provider-exists to accept the agent): + - Write two valid board files + one malformed file. `discover(projectId)` → list has 3 entries; the malformed one has `error != null`; the two valid ones are registered (`WorkflowReadModel.listBoardsForProject` returns 2). Delete one valid file, `discover` again → that board is unregistered (registry/read-model no longer has it) and dropped from the list. Assert deterministic `boardId === \`${projectId}__${slug}\``. +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** interface `{ discover: (projectId) => Effect, E>; list: (projectId) => Effect, E> }`. Layer: + - Resolve `workspaceRoot` via `ProjectWorkspaceResolver`. + - List `*.json` under `/.t3/boards/` (FileSystem `readDirectory`; tolerate missing dir → []). + - For each: `slug = basename without .json`, `boardId = BoardId.make(\`${projectId}__${slug}\`)`, `relativePath = .t3/boards/.json`. Read+`Schema.decodeUnknown(WorkflowDefinition)`+`lintWorkflowDefinition`. Valid → `WorkflowFileLoader.loadAndRegister({ boardId, projectId, workspaceRoot, relativePath })`, entry `{ …, error: null }`. Invalid → entry `{ …, name: , error: }` (do NOT register). + - Compute the set of current boardIds; for any previously-cached board not present now → `BoardRegistry.unregister` + `WorkflowReadModel.deleteBoard`. + - Cache the list in a `Ref>`; `list` returns the cache (discovering if absent). +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): BoardDiscovery (scan/register/unregister .t3/boards)"` + +--- + +## Task 8: RPC handlers — listBoards, createBoard; remove registerBoardFromFile + +**Files:** Modify `workflow/Layers/WorkflowRpcHandlers.ts` (+ a small `CreateBoard` helper, inline or `Layers/CreateBoard.ts`); `ws.ts` (`RPC_REQUIRED_SCOPE`); tests in `WorkflowRpcHandlers.test.ts`. + +- [ ] **Step 1: Failing test** — drive the handler map: `listBoards({ projectId })` returns the discovered entries; `createBoard({ projectId, name, agent })` writes `.t3/boards/.json` under the resolved root, returns `{ boardId, snapshot }`, and a subsequent `listBoards` includes it; a second `createBoard` with the same name produces a `-2` slug. Assert the handler never reads a client path. +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** + - `listBoards`: `BoardDiscovery.discover(projectId)` (or `list`), return entries. + - `createBoard`: resolve `workspaceRoot` via `ProjectWorkspaceResolver`; gather existing slugs (scan dir); `slug = uniqueBoardSlug(slugifyBoardName(name), existing)`; `def = defaultBoardDefinition({ name, agent })`; **exclusively** write `/.t3/boards/.json` (see note); `loadAndRegister({ boardId: \`${projectId}__${slug}\`, projectId, workspaceRoot, relativePath })`; build the snapshot (reuse the existing `boardSnapshot` builder, now including `projectId`); return `{ boardId, snapshot }`. Emit `boardsChanged({ projectId })` if the push bus is wired (Task 11) — otherwise the client re-fetches after the call resolves. + - Remove the `registerBoardFromFile` handler entry. + - **Exclusive write:** `WorkspaceFileSystem.writeFile` overwrites; add an exclusive-create path (Node `writeFile(path, data, { flag: "wx" })` via the FileSystem, or a pre-check that the file does not exist immediately before writing under the same effect). Fail with a typed error if it exists. + - In `ws.ts`: add `[WORKFLOW_WS_METHODS.listBoards, AuthWorkflowReadScope]` and `[WORKFLOW_WS_METHODS.createBoard, AuthWorkflowOperateScope]` to `RPC_REQUIRED_SCOPE`; remove the `registerBoardFromFile` scope entry. +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): listBoards/createBoard RPC handlers; remove registerBoardFromFile"` + +--- + +## Task 9: Wire runtime + provide new layers + discovery on project resolution + +**Files:** Modify `workflow/WorkflowRuntimeLive.ts` (provide `BoardDiscoveryLive`, `ProjectWorkspaceResolverLive`); attach discovery + a `.t3/boards/` file-watcher at the server project-resolution/startup hook (mirror `serverSettings.ts` watcher). Add a `boardsChanged` push if straightforward; else rely on client refetch. + +- [ ] **Step 1:** Provide the new layers in `WorkflowRuntimeLive`. Typecheck. +- [ ] **Step 2:** Attach discovery: when a project becomes known/visible (confirm the hook used by the existing runtime/`serverRuntimeStartup.ts`), call `BoardDiscovery.discover(projectId)` and start a debounced watcher on `/.t3/boards/` that re-runs discovery + emits `boardsChanged`. Mirror the debounce/reload shape in `serverSettings.ts:481-517`. +- [ ] **Step 3:** Typecheck + run the full workflow suite green. +- [ ] **Step 4: Commit** `… -m "feat(workflow): wire BoardDiscovery + watcher into runtime"` + +--- + +## Task 10: Client RPC wiring (client-runtime + EnvironmentApi + boardRpc) + +**Files:** Modify `packages/client-runtime/src/wsRpcClient.ts`, `apps/web/src/environmentApi.ts`, `apps/web/src/workflow/boardRpc.ts`. + +- [ ] **Step 1:** In `wsRpcClient.ts`, add `listBoards`/`createBoard` to the `workflow` client group (mirror the existing `workflow.*` request methods at `:390-410`); remove `registerBoardFromFile`. +- [ ] **Step 2:** In `environmentApi.ts`, expose `workflow.listBoards`/`workflow.createBoard` (mirror existing entries at `:61-72`); remove `registerBoardFromFile`. +- [ ] **Step 3:** In `apps/web/src/workflow/boardRpc.ts`, add helpers `listBoards(api, projectId)` and `createBoard(api, { projectId, name, agent })`; remove the register helper. +- [ ] **Step 4:** Typecheck. Commit `… -m "feat(web): client wiring for listBoards/createBoard"` + +--- + +## Task 11: resolveRecentAgent (web, pure core + store wrapper) + +**Files:** Create `apps/web/src/workflow/resolveRecentAgent.ts` + `resolveRecentAgent.test.ts`. + +- [ ] **Step 1: Failing test** +```typescript +// apps/web/src/workflow/resolveRecentAgent.test.ts +import { describe, expect, it } from "vite-plus/test"; +import { pickRecentAgent } from "./resolveRecentAgent.ts"; + +describe("pickRecentAgent", () => { + const avail = (id: string) => id === "codex" || id === "claude_main"; + it("prefers sticky when available", () => { + expect(pickRecentAgent({ + sticky: { instance: "codex", model: "gpt-5.4" }, + recentThread: { instance: "claude_main", model: "sonnet" }, + defaultChoice: { instance: "claude_main", model: "sonnet" }, + isAvailable: avail, + })).toEqual({ instance: "codex", model: "gpt-5.4" }); + }); + it("falls through unavailable sticky to recent thread", () => { + expect(pickRecentAgent({ + sticky: { instance: "ghost", model: "x" }, + recentThread: { instance: "claude_main", model: "sonnet" }, + defaultChoice: null, isAvailable: avail, + })).toEqual({ instance: "claude_main", model: "sonnet" }); + }); + it("returns null when nothing is available", () => { + expect(pickRecentAgent({ sticky: null, recentThread: null, defaultChoice: null, isAvailable: avail })).toBeNull(); + }); +}); +``` +- [ ] **Step 2: Run → FAIL.** `cd apps/web && pnpm exec vp test run src/workflow/resolveRecentAgent.test.ts` +- [ ] **Step 3: Implement** the pure `pickRecentAgent(sources)` (precedence sticky → recentThread → default, each gated by `isAvailable`), plus `resolveRecentAgent()` that reads the real stores: composer sticky (`composerDraftStore` `stickyActiveProvider` + `stickyModelSelectionByProvider`), most-recent **full** thread's `modelSelection` (NOT sidebar summaries — read `Thread.modelSelection`), and the first available provider via `providerInstances.ts` helpers + `DEFAULT_MODEL_BY_PROVIDER`. `isAvailable` checks the configured enabled+installed instances. +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(web): resolveRecentAgent helper"` + +--- + +## Task 12: Board-list store slice (web) + +**Files:** Modify `apps/web/src/store.ts` (or a new `apps/web/src/workflow/boardListState.ts` slice) + `boardListState.test.ts`. + +- [ ] **Step 1: Failing test** (`vite-plus/test`) — a reducer `applyBoardList(state, projectId, entries)` stores per-project board entries; `selectBoardsForProject(state, projectId)` returns them; applying again replaces. +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** a `boardsByProjectId: Record>` slice + `setProjectBoards(projectId, entries)` action + selector. Fetch via `listBoards` on project view and re-fetch on the `boardsChanged` push (or after `createBoard`). +- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(web): per-project board-list store slice"` + +--- + +## Task 13: Sidebar — board rows + "Add board" + +**Files:** Modify `apps/web/src/components/Sidebar.tsx`. + +- [ ] **Step 1:** In `SidebarProjectItem`, render the project's boards (from `selectBoardsForProject`) as **sub-items** below/within the project's thread list, each a row with the `SquareKanban` Lucide icon and the board `name`, linking to `/_chat/$environmentId/board?boardId=` (use the existing route nav). A board entry with `error != null` renders with a warning affordance + tooltip. +- [ ] **Step 2:** Add an **"Add board"** action in the project header. Mirror `handleCreateThreadClick`: if `memberProjects.length > 1`, show the `contextMenu` member picker first; then `const agent = resolveRecentAgent()` (disable the action with a tooltip if null), `await createBoard(api, { projectId: member.id, name: nextDefaultBoardName(existingBoardNames), agent })`, then navigate to the returned board. Add `nextDefaultBoardName` (pure: "Workflow board", "Workflow board 2", …) — colocate + unit-test it. +- [ ] **Step 3:** Manual verify in the running app (Argent/browser per your workflow): a project shows an "Add board"; clicking creates a board that appears as a sidebar row and opens. +- [ ] **Step 4: Commit** `… -m "feat(web): sidebar board rows + Add board affordance"` + +--- + +## Task 14: Board route cleanup + +**Files:** Modify `apps/web/src/routes/_chat.$environmentId.board.tsx`, `apps/web/src/components/board/BoardHeaderControls.tsx`. + +- [ ] **Step 1:** The route already reads `boardId` from search — keep that, but it now comes from real sidebar nav. Remove the manual **"Register board"** button (`BoardHeaderControls` `onRegisterBoard`/`canRegisterBoard`) and the `registrationProject = projects[0]` derivation + `handleRegisterBoard`. The board's project comes from `snapshot.projectId` (Task 3) where needed. +- [ ] **Step 2:** When `boardId` resolves to no board (deleted file), render an explicit **"Board not found"** state (generalize the existing "No board selected"). Keep `subscribeBoard`, `createTicket`, `moveTicket`, `resolveApproval`, `runLane`, the drawer, and ticket flows unchanged. +- [ ] **Step 3:** Typecheck; web tests green; manual verify the board opens from the sidebar without any "Register" step. +- [ ] **Step 4: Commit** `… -m "feat(web): board route uses real boardId; drop manual registration"` + +--- + +## Milestone Done — Definition of Done +- [ ] `pnpm exec vp run typecheck` and `pnpm exec vp check` pass; server (`src/workflow`), contracts, and web (`src/workflow`) suites green. +- [ ] End-to-end (manual): a project shows "Add board"; one click creates a `.t3/boards/.json` (templated with your most recent agent), the board appears as a sidebar row with the board icon, and opens directly — no `?boardId=` fiddling, no "Register board" button. A hand-authored board file also appears; deleting a file removes its row. +- [ ] No client-supplied repo path reaches the server; `registerBoardFromFile` is gone. + +--- + +## Notes for the implementer +- **Confirm real signatures by reading the files** the spec/plan cite (`ProjectionSnapshotQuery`, `WorkflowFileLoader`, `BoardRegistry`, `WorkflowReadModel`, `WorkspaceFileSystem`, `wsRpcClient`, `environmentApi`, `Sidebar.tsx`, the board route). Mirror the v1 workflow patterns; never invent APIs. +- **Exclusive-create write** is the one filesystem subtlety — don't overwrite an existing slug file. +- **Discovery resolves the root from `projectId`** — the client never passes a path. This also closes the pre-existing `registerBoardFromFile` trust hole. +- **`modelSelection` is on full threads, not sidebar summaries.** +- Keep ticket/board runtime behavior (v1) untouched; this feature is creation/listing/navigation only. From 76d09ca5113e9cb51b48a6eda6a3ae8236be8438 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:40:26 -0400 Subject: [PATCH 060/295] docs: fold codex review into board-creation UX plan - Task 5: keep repo compiling (shim the existing registerBoardFromFile caller to the new loader signature until Task 8 removes it). - Task 3: BoardSnapshot.projectId is top-level; remove registerBoardFromFile everywhere (ipc.ts, client runtime, EnvironmentApi, scope, mocks). - Task 6: resolver returns a dedicated tagged error; unwrap Option from getProjectShellById; don't mistype projection errors. - Task 8: add WorkspaceFileSystem.createFileExclusive (wx); createBoard uses it. - Task 9: discovery is on-demand via listBoards; server file-watcher/boardsChanged push explicitly deferred (no generic project-visible hook exists). - Task 11: read recent agent from thread SHELLS; filter providers by installed. - Task 12: refetch on mount + after createBoard (no push). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-07-board-creation-ux.md | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-06-07-board-creation-ux.md b/docs/superpowers/plans/2026-06-07-board-creation-ux.md index d0cf28c1a9f..32604366507 100644 --- a/docs/superpowers/plans/2026-06-07-board-creation-ux.md +++ b/docs/superpowers/plans/2026-06-07-board-creation-ux.md @@ -195,7 +195,7 @@ describe("board creation contracts", () => { - [ ] **Step 3: Implement** in `packages/contracts/src/workflow.ts`: - Add `ProjectId` import if not present. - Add `listBoards` + `createBoard` to `WORKFLOW_WS_METHODS`. - - Add `projectId: ProjectId` to the `BoardSnapshot.board` struct (or top-level `BoardSnapshot`, matching where it's defined). + - Add `projectId` **top-level** on `BoardSnapshot`: `BoardSnapshot = Schema.Struct({ projectId: ProjectId, board: , tickets: })`. (The test asserts top-level `BoardSnapshot.fields.projectId` — keep it top-level, not nested under `board`.) The `boardSnapshot` builder in `WorkflowRpcHandlers.ts:91` must then return `projectId` (the board row already carries `project_id`). - Add: ```typescript export const BoardListEntry = Schema.Struct({ @@ -223,7 +223,7 @@ export const WsWorkflowCreateBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.createBoard error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), }); ``` - Remove `WsWorkflowRegisterBoardFromFileRpc` and its `WORKFLOW_WS_METHODS.registerBoardFromFile` entry (and any group registration). + Remove `WsWorkflowRegisterBoardFromFileRpc` and its `WORKFLOW_WS_METHODS.registerBoardFromFile` entry and group registration. **Also remove every other reference** so the repo compiles: the transport/IPC definition (`packages/contracts/src/ipc.ts:624`), the client-runtime method (Task 10), `EnvironmentApi` (Task 10), the `ws.ts` scope entry (Task 8), the handler (Task 8), and any test mocks. Grep `registerBoardFromFile` to find them all. - [ ] **Step 5: Run → PASS. Step 6: Commit** `git add packages/contracts/src/workflow.ts packages/contracts/src/rpc.ts packages/contracts/src/workflow.test.ts && git commit -m "feat(workflow): board list/create RPC contracts; drop registerBoardFromFile"` --- @@ -272,8 +272,8 @@ Currently `loadAndRegister({ boardId, projectId, filePath, repoRoot })` reads an - [ ] **Step 1: Failing test** — assert that after `loadAndRegister({ …, workspaceRoot: tmp, relativePath: ".t3/boards/x.json" })`, the registered board's `workflowFilePath` is the **relative** path, while the file is read from the absolute join. (Use a temp dir with a valid board file; stub provider-exists to accept the agent.) - [ ] **Step 2: Run → FAIL.** -- [ ] **Step 3: Implement** — change the input to `{ boardId, projectId, workspaceRoot, relativePath, agentInstanceExists? }`; read `path.resolve(workspaceRoot, relativePath)`; pass `relativePath` to `WorkflowReadModel.registerBoard({ workflowFilePath: relativePath, … })`. Update the one existing caller (none in prod yet besides handlers being rewritten in Task 8). -- [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "refactor(workflow): split loader read path from persisted relative path"` +- [ ] **Step 3: Implement** — change the input to `{ boardId, projectId, workspaceRoot, relativePath }`; read `path.resolve(workspaceRoot, relativePath)`; pass `relativePath` to `WorkflowReadModel.registerBoard({ workflowFilePath: relativePath, … })`. **Keep the repo compiling:** the current `registerBoardFromFile` handler (`WorkflowRpcHandlers.ts:149`) calls `loadAndRegister({ boardId, projectId, filePath, repoRoot })`; update that single call site to map `{ workspaceRoot: repoRoot, relativePath: filePath }` (a temporary shim — Task 8 deletes that handler). Also update existing `WorkflowFileLoader.test.ts` callers to the new shape. +- [ ] **Step 4: Run → PASS** (loader test + the existing handler test still compile/green). **Step 5: Commit** `… -m "refactor(workflow): split loader read path from persisted relative path"` --- @@ -283,7 +283,7 @@ Currently `loadAndRegister({ boardId, projectId, filePath, repoRoot })` reads an - [ ] **Step 1: Failing test** — stub the underlying project query; assert `resolve(projectId)` returns the project's `workspaceRoot`, and fails with a typed error for an unknown project. - [ ] **Step 2: Run → FAIL.** -- [ ] **Step 3: Implement** interface `{ resolve: (projectId) => Effect }`. `*Live` reads `ProjectionSnapshotQuery.getProjectShellById(projectId)` (or `ProjectionProjectRepository`) → `workspaceRoot`; confirm the exact method/field from `apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts`. Map a missing project to a typed error. +- [ ] **Step 3: Implement** interface `{ resolve: (projectId) => Effect }` with a dedicated `Schema.TaggedError` (`ProjectWorkspaceResolverError`). `*Live` calls `ProjectionSnapshotQuery.getProjectShellById(projectId)` which returns `Effect, ProjectionRepositoryError>` (confirm at `ProjectionSnapshotQuery.ts:120`): **unwrap the `Option`** (`Option.match`), map `None` → `ProjectWorkspaceResolverError("project not found")`, map the underlying `ProjectionRepositoryError` → the same tagged error explicitly, and return the shell's `workspaceRoot` field. Do **not** mis-type this as `WorkflowEventStoreError`. - [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): project workspace-root resolver"` --- @@ -315,22 +315,23 @@ Scans `/.t3/boards/*.json` for a project, classifies valid/invali - [ ] **Step 2: Run → FAIL.** - [ ] **Step 3: Implement** - `listBoards`: `BoardDiscovery.discover(projectId)` (or `list`), return entries. - - `createBoard`: resolve `workspaceRoot` via `ProjectWorkspaceResolver`; gather existing slugs (scan dir); `slug = uniqueBoardSlug(slugifyBoardName(name), existing)`; `def = defaultBoardDefinition({ name, agent })`; **exclusively** write `/.t3/boards/.json` (see note); `loadAndRegister({ boardId: \`${projectId}__${slug}\`, projectId, workspaceRoot, relativePath })`; build the snapshot (reuse the existing `boardSnapshot` builder, now including `projectId`); return `{ boardId, snapshot }`. Emit `boardsChanged({ projectId })` if the push bus is wired (Task 11) — otherwise the client re-fetches after the call resolves. + - `createBoard`: resolve `workspaceRoot` via `ProjectWorkspaceResolver`; gather existing slugs (scan `/.t3/boards/*.json`); `slug = uniqueBoardSlug(slugifyBoardName(name), existing)`; `def = defaultBoardDefinition({ name, agent })`; **exclusively create** `/.t3/boards/.json` (see note); `loadAndRegister({ boardId: \`${projectId}__${slug}\`, projectId, workspaceRoot, relativePath: \`.t3/boards/${slug}.json\` })`; build the snapshot via the `boardSnapshot` builder (now returning top-level `projectId`); return `{ boardId, snapshot }`. **No `boardsChanged` push in v1** — the client re-fetches `listBoards` after `createBoard` resolves (Task 12). The file-watcher live-update is explicitly deferred. - Remove the `registerBoardFromFile` handler entry. - - **Exclusive write:** `WorkspaceFileSystem.writeFile` overwrites; add an exclusive-create path (Node `writeFile(path, data, { flag: "wx" })` via the FileSystem, or a pre-check that the file does not exist immediately before writing under the same effect). Fail with a typed error if it exists. + - **Exclusive-create API:** the current `WorkspaceFileSystem.writeFile` always creates parents and **overwrites** (`WorkspaceFileSystem.ts:20-40`). Add a new method `createFileExclusive({ projectRoot, relativePath, contents })` that creates parents then writes with Node/Effect exclusive semantics (`{ flag: "wx" }`), failing with a typed error if the path exists. `createBoard` uses `createFileExclusive`, **not** `writeFile`. Add a test for the exclusive method (existing file → typed error). - In `ws.ts`: add `[WORKFLOW_WS_METHODS.listBoards, AuthWorkflowReadScope]` and `[WORKFLOW_WS_METHODS.createBoard, AuthWorkflowOperateScope]` to `RPC_REQUIRED_SCOPE`; remove the `registerBoardFromFile` scope entry. - [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(workflow): listBoards/createBoard RPC handlers; remove registerBoardFromFile"` --- -## Task 9: Wire runtime + provide new layers + discovery on project resolution +## Task 9: Wire runtime + provide new layers (on-demand discovery; watcher deferred) -**Files:** Modify `workflow/WorkflowRuntimeLive.ts` (provide `BoardDiscoveryLive`, `ProjectWorkspaceResolverLive`); attach discovery + a `.t3/boards/` file-watcher at the server project-resolution/startup hook (mirror `serverSettings.ts` watcher). Add a `boardsChanged` push if straightforward; else rely on client refetch. +**Files:** Modify `workflow/WorkflowRuntimeLive.ts` (and wherever workflow services are provided into the server runtime). -- [ ] **Step 1:** Provide the new layers in `WorkflowRuntimeLive`. Typecheck. -- [ ] **Step 2:** Attach discovery: when a project becomes known/visible (confirm the hook used by the existing runtime/`serverRuntimeStartup.ts`), call `BoardDiscovery.discover(projectId)` and start a debounced watcher on `/.t3/boards/` that re-runs discovery + emits `boardsChanged`. Mirror the debounce/reload shape in `serverSettings.ts:481-517`. -- [ ] **Step 3:** Typecheck + run the full workflow suite green. -- [ ] **Step 4: Commit** `… -m "feat(workflow): wire BoardDiscovery + watcher into runtime"` +**Discovery is on-demand in v1:** the `listBoards` handler (Task 8) calls `BoardDiscovery.discover(projectId)`, so a project's boards are (re)scanned whenever the sidebar requests the list. There is **no generic "project became visible" server hook** in the current code (`serverRuntimeStartup.ts` only auto-bootstraps the cwd project + runs recovery), so a server-side file-watcher + live push is **explicitly deferred** — hand-authored/deleted board files reflect on the next `listBoards` (sidebar mount / project switch / after createBoard). + +- [ ] **Step 1:** Provide `ProjectWorkspaceResolverLive` and `BoardDiscoveryLive` in the workflow runtime layer (mirror how the other workflow services are provided). Ensure `ProjectionSnapshotQuery` is in scope for the resolver. +- [ ] **Step 2:** Typecheck (`pnpm exec vp run typecheck`) and run the full workflow suite (`cd apps/server && pnpm exec vp test run src/workflow`) green. +- [ ] **Step 3: Commit** `… -m "feat(workflow): provide BoardDiscovery + workspace resolver in runtime"` --- @@ -378,7 +379,11 @@ describe("pickRecentAgent", () => { }); ``` - [ ] **Step 2: Run → FAIL.** `cd apps/web && pnpm exec vp test run src/workflow/resolveRecentAgent.test.ts` -- [ ] **Step 3: Implement** the pure `pickRecentAgent(sources)` (precedence sticky → recentThread → default, each gated by `isAvailable`), plus `resolveRecentAgent()` that reads the real stores: composer sticky (`composerDraftStore` `stickyActiveProvider` + `stickyModelSelectionByProvider`), most-recent **full** thread's `modelSelection` (NOT sidebar summaries — read `Thread.modelSelection`), and the first available provider via `providerInstances.ts` helpers + `DEFAULT_MODEL_BY_PROVIDER`. `isAvailable` checks the configured enabled+installed instances. +- [ ] **Step 3: Implement** the pure `pickRecentAgent(sources)` (precedence sticky → recentThread → default, each gated by `isAvailable`), plus `resolveRecentAgent()` that reads the real stores: + - **Sticky:** `composerDraftStore` `stickyActiveProvider` + `stickyModelSelectionByProvider[stickyActiveProvider]`. + - **Recent thread:** use **thread shells**, not full threads or sidebar summaries — `store.threadShellById` / `selectThreadShellsAcrossEnvironments` (`ThreadShell.modelSelection` exists; `SidebarThreadSummary` intentionally lacks it). Sort by `updatedAt ?? createdAt` desc; take the latest `modelSelection.instanceId`/`model`. + - **Default:** the first provider entry from `deriveProviderInstanceEntries` filtered by `entry.enabled && entry.installed && entry.isAvailable`; model = `entry.models.find((m) => !m.isCustom)?.slug ?? entry.models[0]?.slug ?? DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]`. + - `isAvailable(instance)` = that instance exists among the enabled+installed+available entries. - [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(web): resolveRecentAgent helper"` --- @@ -389,7 +394,7 @@ describe("pickRecentAgent", () => { - [ ] **Step 1: Failing test** (`vite-plus/test`) — a reducer `applyBoardList(state, projectId, entries)` stores per-project board entries; `selectBoardsForProject(state, projectId)` returns them; applying again replaces. - [ ] **Step 2: Run → FAIL.** -- [ ] **Step 3: Implement** a `boardsByProjectId: Record>` slice + `setProjectBoards(projectId, entries)` action + selector. Fetch via `listBoards` on project view and re-fetch on the `boardsChanged` push (or after `createBoard`). +- [ ] **Step 3: Implement** a `boardsByProjectId: Record>` slice + `setProjectBoards(projectId, entries)` action + selector. Fetch via `listBoards` when the project's sidebar section mounts/expands and **re-fetch right after `createBoard` resolves** (v1 has no live push — see Task 9). Hand-authored/deleted files reflect on the next such fetch. - [ ] **Step 4: Run → PASS. Step 5: Commit** `… -m "feat(web): per-project board-list store slice"` --- From 6745e4d80fb049929e5e06d4d82010b52f8aca71 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:42:43 -0400 Subject: [PATCH 061/295] docs: add codex build prompt for board-creation UX + prior review artifacts --- .../plans/codex-board-ux-prompt.txt | 32 +++++++ .../plans/codex-build-prompt-short.txt | 19 ++++ docs/superpowers/plans/codex-build-prompt.md | 87 +++++++++++++++++++ ...7-workflow-boards-v1-adversarial-review.md | 50 +++++++++++ docs/superpowers/reviews/codex-fix-prompt.txt | 27 ++++++ .../reviews/codex-round4-prompt.txt | 23 +++++ 6 files changed, 238 insertions(+) create mode 100644 docs/superpowers/plans/codex-board-ux-prompt.txt create mode 100644 docs/superpowers/plans/codex-build-prompt-short.txt create mode 100644 docs/superpowers/plans/codex-build-prompt.md create mode 100644 docs/superpowers/reviews/2026-06-07-workflow-boards-v1-adversarial-review.md create mode 100644 docs/superpowers/reviews/codex-fix-prompt.txt create mode 100644 docs/superpowers/reviews/codex-round4-prompt.txt diff --git a/docs/superpowers/plans/codex-board-ux-prompt.txt b/docs/superpowers/plans/codex-board-ux-prompt.txt new file mode 100644 index 00000000000..0fe9e374762 --- /dev/null +++ b/docs/superpowers/plans/codex-board-ux-prompt.txt @@ -0,0 +1,32 @@ +Implement the "Board Creation UX" feature in the t3code monorepo by executing a 14-task TDD plan end to end, committing after each task. Work autonomously; stop only on a real blocker. + +Repo: /Users/chris/Developer/t3code, branch ft/hyperion (stay on it, no PRs). pnpm, Effect 4 beta, TypeScript, React 19; tooling is `vp`. + +READ FIRST, in full: +- docs/superpowers/plans/2026-06-07-board-creation-ux.md (14 tasks, exact code/steps) +- docs/superpowers/specs/2026-06-07-board-creation-ux-design.md (invariants) + +Feature: one-click "Add board" per project -> file-backed boards (.t3/boards/.json) auto-discovered + registered server-side, listed in the sidebar like chats (SquareKanban icon), defaulting to the user's most recent agent. Builds on Workflow Boards v1 under apps/server/src/workflow/**. + +Per task (TDD, mandatory): write the failing test from the plan -> run, confirm it FAILS for the right reason -> minimal impl -> run, confirm PASS -> commit with the plan's message (one commit per task). Never impl before its test; never finish without a passing test; never skip/weaken a test. Do tasks 1->14 in order. + +Codebase is the source of truth: where the plan says "confirm signature" or differs from reality, read the real file -- never invent APIs. Mirror v1 patterns (Services/Layers split, @effect/vitest it.layer/it.effect, SQL, Rpc.make + WsRpcGroup + ws.ts handler + RPC_REQUIRED_SCOPE + wsRpcClient + EnvironmentApi). + +Invariants (do not regress): +- Repo COMPILES and tests PASS at every task boundary. Task 5 changes loadAndRegister to {boardId,projectId,workspaceRoot,relativePath}; in the SAME task update the existing registerBoardFromFile caller to map {workspaceRoot:repoRoot, relativePath:filePath} + fix loader test callers so it compiles. Task 8 deletes that handler. +- createBoard accepts ONLY {projectId,name,agent} -- never a client path. Server resolves workspaceRoot from projectId via ProjectionSnapshotQuery.getProjectShellById (unwrap the Option; dedicated tagged error on missing project). Remove registerBoardFromFile EVERYWHERE (rpc.ts, ipc.ts, wsRpcClient, EnvironmentApi, ws.ts scope, handler, mocks) -- grep it. +- BoardSnapshot.projectId is TOP-LEVEL (not under board); boardSnapshot returns it from the board row. +- Writes use a NEW WorkspaceFileSystem.createFileExclusive (wx) that fails if the path exists; createBoard uses it, not writeFile. +- Discovery is on-demand via the listBoards handler; server file-watcher / "boardsChanged" push is DEFERRED. Web store refetches listBoards on sidebar mount and after createBoard. +- Deterministic boardId = `${projectId}__${slug}`; re-scan idempotent; deleted files unregister (BoardRegistry.unregister + WorkflowReadModel.deleteBoard). +- resolveRecentAgent reads thread SHELLS (ThreadShell.modelSelection), not full threads/summaries; default provider filtered enabled && installed && isAvailable. +- "Add board" mirrors "New thread": grouped project (memberProjects.length>1) picks the concrete member first. +- Keep all v1 ticket/board RUNTIME behavior untouched; this is creation/listing/navigation only. + +Verify: per task run that test (cd apps/server && pnpm exec vp test run src/; or pnpm --filter @t3tools/contracts test; or cd apps/web && pnpm exec vp test run src/). At task boundaries + end: `pnpm exec vp run typecheck`, `pnpm exec vp check` (fix via `pnpm exec vp fmt `), suites green. If vp missing, `pnpm install` first. + +Keep going through all 14 tasks. Stop only if a needed API truly doesn't exist/contradicts the plan unresolvably, or a test can't pass without guessing -- then report the exact blocker, file/symbol, what you tried, and 1-2 options. After each task, post a 1-line summary and continue. + +Done when: 14 tasks complete; typecheck + check pass; contracts/server(src/workflow)/web(src/workflow) suites green; a project shows "Add board" that one-click creates a templated .t3/boards/.json (recent agent baked in), the board appears as a sidebar row and opens directly with no Register step; registerBoardFromFile is gone. + +Begin with Task 1. diff --git a/docs/superpowers/plans/codex-build-prompt-short.txt b/docs/superpowers/plans/codex-build-prompt-short.txt new file mode 100644 index 00000000000..43e8c160c8e --- /dev/null +++ b/docs/superpowers/plans/codex-build-prompt-short.txt @@ -0,0 +1,19 @@ +Implement "Workflow Boards v1" in the t3code monorepo by executing 5 plan docs in order, end-to-end, with strict TDD, committing after each task. Work autonomously; stop only on a real blocker. + +Repo: /Users/chris/Developer/t3code, branch ft/hyperion (stay on it, no PRs). pnpm workspaces, Effect 4 beta, TypeScript, @effect/sql-sqlite-bun, React 19 web; tooling is `vp` (vite-plus). New code: apps/server/src/workflow/ (interfaces in Services/, impls in Layers/), packages/contracts/src/workflow.ts, apps/web/src/workflow + components/board. + +Plans, numeric order m1->m5: docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md, -m2-engine-core.md, -m3-durable-execution.md, -m4-ticket-diff.md, -m5-rpc-ui.md. Read-only context: docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md. + +Per task (TDD, mandatory): write the failing test from the plan -> run it, confirm it FAILS for the right reason -> write minimal impl -> run it, confirm it PASSES -> commit with the plan's commit message (one focused commit per task). Never impl before its test; never finish a task without a passing test; never skip/weaken/`skip` a test. + +Codebase is the source of truth: where a plan says "confirm signature" or differs from reality, open the real file and follow it -- never invent APIs. Mirror nearest existing equivalents: event store/SQL -> apps/server/src/persistence/{Services,Layers}/OrchestrationEventStore.ts; projections -> orchestration/ProjectionPipeline; migrations -> persistence/Migrations/001_*.ts + Migrations.ts; tests -> OrchestrationEventStore.test.ts (@effect/vitest it.layer/it.effect); RPC -> contracts/rpc.ts (Rpc.make) + ws.ts (WsRpcGroup.of, observeRpcEffect/observeRpcStreamEffect, RPC_REQUIRED_SCOPE) + the client-group registration exposing client.workflow.*; web -> store.ts, Sidebar.tsx (dnd-kit), routes/ (createFileRoute), client-runtime subscribe, vite-plus/test. + +Open items: (1) migrations use placeholder numbers (0XX/0AA...); before each, read Migrations.ts, find the highest migrationEntries index, use the next integer, name files to match. (2) in WorkflowIdsLive use the repo's canonical id util (how ThreadId/CommandId are minted) if one exists, not crypto.randomUUID. + +Verify: per task run that test (pnpm --filter @t3tools/server test -- ; or @t3tools/contracts / @t3tools/web). Per milestone, before advancing, make green: `pnpm exec vp run typecheck`, `pnpm exec vp check` (fix via `pnpm exec vp fmt `), and the milestone's package tests. If vp is missing, `pnpm install` first. Don't advance until the milestone's Definition of Done passes. + +Scope: implement only the current milestone; no v2 features (script steps, predicates, per-step routing, WIP enforcement, visual editor); pause is deferred (render abort disabled if not fully wired). Keep the stubbable-port + *Live-bridge structure: unit-test logic against stubs, real wiring in *Live. + +Keep going through all 5 milestones without asking. Stop only if a needed API truly doesn't exist/contradicts the plan unresolvably, a test can't pass without guessing or breaking scope, or a DoD can't be met -- then report the exact blocker, file/symbol, what you tried, and 1-2 options. After each milestone, post a 2-3 line summary (built / tests / DoD confirmed) and continue. + +Begin with Milestone 1, Task 1. \ No newline at end of file diff --git a/docs/superpowers/plans/codex-build-prompt.md b/docs/superpowers/plans/codex-build-prompt.md new file mode 100644 index 00000000000..9ee18edb57a --- /dev/null +++ b/docs/superpowers/plans/codex-build-prompt.md @@ -0,0 +1,87 @@ +# Codex build prompt — Workflow Boards v1 (M1→M5) + +> Paste everything below the line into Codex (goals mode). It is self-contained. + +--- + +You are implementing **Workflow Boards v1** in the t3code monorepo. Your job is to execute five committed implementation plans, in order, end to end, using strict TDD, committing after every task, until all five milestones are complete and verified. Keep working autonomously; only stop for a genuine blocker (see "When to stop"). + +## Repo & environment + +- Working dir: `/Users/chris/Developer/t3code`. Branch: `ft/hyperion` (stay on it; do not open PRs). +- Monorepo: pnpm workspaces, Effect 4 (beta), TypeScript, `@effect/sql-sqlite-bun`, React 19 web. Tooling is `vp` (vite-plus). +- New server code lives under `apps/server/src/workflow/` (split: interfaces in `Services/`, implementations in `Layers/`). Contracts in `packages/contracts/src/workflow.ts`. Web under `apps/web/src/workflow/` and `apps/web/src/components/board/`. + +## The plans (execute strictly in this order) + +1. `docs/superpowers/plans/2026-06-07-workflow-boards-v1-m1-foundation.md` — contracts, file schema + linter, `workflow_events` store, projections, read model. +2. `docs/superpowers/plans/2026-06-07-workflow-boards-v1-m2-engine-core.md` — state machine (pipelines, routing, lane-entry tokens, drag, approvals, concurrency) with provider stubbed. +3. `docs/superpowers/plans/2026-06-07-workflow-boards-v1-m3-durable-execution.md` — worktree lease, setup-run gate, provider-dispatch outbox, real executor, durable approvals, recovery. +4. `docs/superpowers/plans/2026-06-07-workflow-boards-v1-m4-ticket-diff.md` — ticket baseline/per-step refs + accumulated diff query. +5. `docs/superpowers/plans/2026-06-07-workflow-boards-v1-m5-rpc-ui.md` — `workflow.*` RPC, file loader, runtime wiring + recovery, board UI + drill-in. + +Reference (read for context, do not modify): `docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md`. + +## How to execute each task (non-negotiable TDD loop) + +For every task in a plan, in order: + +1. **Write the failing test** exactly as the plan specifies (adapt only to match real APIs — see "Codebase is truth"). +2. **Run the test and confirm it FAILS** for the expected reason. +3. **Write the minimal implementation** to make it pass. +4. **Run the test and confirm it PASSES.** +5. **Commit** with the plan's commit message (one focused commit per task; never bundle unrelated changes). + +Never write implementation before its test. Never mark a task done without seeing the test pass. + +## Codebase is the source of truth + +The plans contain concrete code, but where a plan note says "confirm signature/shape" or the real API differs, **open the actual source file and follow the real code** — do not invent or guess APIs. The M2–M5 plans were already code-verified, but still verify before relying on a signature. When implementing an idiom (event store, projection, service+layer, migration, RPC handler, Effect Schema, Vitest test), **mirror the nearest existing equivalent**: + +- Event store / SQL: `apps/server/src/persistence/{Services,Layers}/OrchestrationEventStore.ts`. +- Projection pipeline: `apps/server/src/orchestration/{Services,Layers}/ProjectionPipeline.ts`. +- Service + Layer pattern: any `Context.Service` + `Layer.effect` in `apps/server/src/**`. +- Migration shape + registration: `apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts` and `Migrations.ts`. +- Vitest (`@effect/vitest`, `it.layer`/`it.effect`): `apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts`. +- RPC end to end: `packages/contracts/src/rpc.ts` (`Rpc.make`), `apps/server/src/ws.ts` (`WsRpcGroup.of`, `observeRpcEffect`/`observeRpcStreamEffect`, `RPC_REQUIRED_SCOPE`), and the client group registration so `client.workflow.*` is exposed. +- Web: zustand `apps/web/src/store.ts`; dnd-kit in `apps/web/src/components/Sidebar.tsx`; route `createFileRoute` in `apps/web/src/routes/`; client subscribe in `packages/client-runtime/src/`; tests via `vite-plus/test`. + +If real code forces a deviation from the plan, follow the real code and add a one-line comment noting the deviation. + +## Two known open items to resolve as you go + +- **Migration numbers:** the plans use placeholders (`0XX`, `0AA`, `0BB`, `0CC`, `0DD`). Before creating each migration, open `apps/server/src/persistence/Migrations.ts`, find the current highest index in `migrationEntries`, and use the next integer(s); name the files to match (e.g. `033_WorkflowEvents.ts`). +- **Id generation:** in `WorkflowIdsLive` (M2), use the repo's canonical server-side id utility if one exists (find how `ThreadId`/`CommandId` values are minted) instead of `crypto.randomUUID()`. + +## Verification gates + +- **Per task:** run the specific test from the plan, e.g. `pnpm --filter @t3tools/server test -- .test.ts` (use `@t3tools/contracts` or `@t3tools/web` as appropriate; web uses vite-plus). +- **Per milestone (its "Definition of Done"):** before starting the next milestone, run and make green: + - `pnpm exec vp run typecheck` + - `pnpm exec vp check` (formatting; run `pnpm exec vp fmt ` to fix) + - the milestone's package tests (`pnpm --filter @t3tools/server test`, plus `@t3tools/contracts` / `@t3tools/web` where the milestone touched them) + - If `vp` is unavailable, run `pnpm install` first. +- Do not advance to the next milestone until the current one's Definition of Done passes. + +## Discipline + +- **Scope:** implement only the current milestone's scope. Do not pull v2 features (script steps, predicates, per-step routing, WIP enforcement, visual editor). `pause` is deferred; only `abort` exists in v5, and if not fully wired, render it disabled rather than half-implementing. +- **Ports stay ports:** keep the stubbable-port + `*Live` bridge structure the plans define. Unit-test orchestration logic against stubs; put real-service wiring in `*Live` layers. +- **No silent skips:** if a test is hard to make pass, fix the code — do not delete/weaken the test or `skip` it. +- **Small commits:** one per task, plan's message. Keep the working tree clean between tasks. + +## When to stop and report + +Keep going through all five milestones without asking for confirmation. Stop and report **only** when: + +- A real API needed by the plan genuinely does not exist or contradicts the plan in a way you cannot resolve by reading the code, **or** +- A test cannot be made to pass without violating scope or guessing, **or** +- A milestone's Definition of Done cannot be met. + +When you stop, report: the exact blocker, the file/symbol involved, what you tried, and 1–2 concrete options. Otherwise, after each milestone, post a short summary (what was built, tests added, DoD confirmed) and immediately continue to the next. + +## Overall definition of done (v1 complete) + +All five milestones' DoD met; `vp run typecheck` and `vp check` pass; server/contracts/web tests green. End-to-end: a `.t3/boards/*.json` file becomes a live board; tickets are created manually; agent pipelines run in per-ticket worktrees; the board shows live status including `⏸ waiting on you`; clicking a ticket shows the step timeline, live agent activity, the question/approval inbox (answered inline to resume), and the accumulated diff; manual drag re-routes and supersedes an in-flight pipeline; a server restart recovers in-flight work without duplicate dispatch. + +Begin with Milestone 1, Task 1. diff --git a/docs/superpowers/reviews/2026-06-07-workflow-boards-v1-adversarial-review.md b/docs/superpowers/reviews/2026-06-07-workflow-boards-v1-adversarial-review.md new file mode 100644 index 00000000000..3bcd3bc08a1 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-07-workflow-boards-v1-adversarial-review.md @@ -0,0 +1,50 @@ +# Workflow Boards v1 — Adversarial Review (2026-06-07) + +**Verdict: DO NOT SHIP for real (non-mock) use.** Scaffolding is sound (~80% done), but the real end-to-end path — a multi-step agent pipeline, with questions, in a project worktree, surviving restart — is non-functional. Build is GREEN (`vp typecheck` passes; 53 server + 159 contracts workflow tests pass) but **every test uses stubs** (`StubStepExecutor`, `MockAcpProvider`, stub ports), so the defects below are all in untested real wiring. Findings confirmed by direct code read and an independent opencode pass. + +Each finding: location → why it breaks → fix direction. Fix in priority order. **Reproduce each with a real-path test before fixing** (see "Required tests"). + +## CRITICAL + +1. **Recovery hangs at server startup.** `WorkflowRecovery.recoverTerminalDispatches` (`apps/server/src/workflow/Layers/WorkflowRecovery.ts:92-107`) loops every non-`confirmed` dispatch calling `outbox.awaitTerminal`, which polls turn state with **no timeout** (`ProviderDispatchOutbox.ts:100-106`). Provider sessions don't survive restart, so a mid-flight turn never reaches terminal → `recover()` blocks forever on the first such row, never running `approvals.resume()`/lease cleanup. Runs at startup. **Fix:** bound `awaitTerminal` with a timeout + interruptibility; for `started` dispatches with a dead session, re-dispatch (reset to `pending`) instead of awaiting; make recovery non-blocking (don't serialize blocking awaits; don't block boot). + +## HIGH + +2. **Agent questions never surface as "waiting on you" → pipeline hangs.** `TurnStateReader` maps every non-terminal state (incl. a turn parked awaiting user input) to `running` (`TurnStateReader.ts:13-21`); `RealStepExecutor` only gets ok/failed (`RealStepExecutor.ts:71-117`) — no `awaiting_user` from a real provider. Real agent question → turn stays `running` → `awaitTerminal` polls forever, lease held, user never prompted. Inline-answer works only for explicit `approval` steps. **Fix:** detect pending provider approval/user-input (orchestration `projection_pending_approvals`/activity) and surface `awaiting_user` with provider request metadata; bridge `resolveApproval → ProviderService.respondToUserInput/respondToRequest`. (`WorkflowEngine.resolveApproval` already reads `providerThreadId/providerRequestId/providerResponseKind` off `StepAwaitingUser` — those fields must actually be populated when an agent question is detected.) + +3. **Multi-step pipelines break on step 2.** `WorktreePort.ensureWorktree` calls `git.createWorktree` with `newRefName: workflow/${ticketId}` on EVERY step (`RealStepExecutor.ts:134-150`) — not idempotent; 2nd step fails (branch/worktree exists). Breaks "one worktree per ticket, work accumulates." **Fix:** reuse the existing worktree/branch (look it up; attach), create only on first step. + +4. **Worktree built from the wrong repo.** `ensureWorktree` uses `process.cwd()` and ignores the board's persisted `projectId`/`repoRoot` (`RealStepExecutor.ts:137`). **Fix:** resolve ticket → board → project `rootPath` and branch from there (thread project context into the executor). + +5. **File-based instructions aren't resolved.** `RealStepExecutor.ts:61-62` sends literal `@@file:`; the loader only checks existence. So `{file:"prompts/x.md"}` delivers a marker, not the prompt. **Fix:** read file contents (snapshot into the run/version snapshot) and send as the instruction. + +6. **Approval steps don't survive restart.** `DurableApprovalResume.resume()` only re-arms the in-memory `ApprovalGate` (`park`) — nothing re-drives the parked pipeline (`DurableApprovalResume.ts:55-67`). Original fiber died at restart; later `resolveApproval` resolves a deferred nobody awaits; post-approval steps never run. **Fix:** on recovery, re-invoke the pipeline from the parked step (model approval as a resumable saga step, not an in-fiber `await`). + +## MEDIUM + +7. **Manual drag is a soft supersede only** (`WorkflowEngine.ts:285-303`) — changes the lane-entry token but doesn't interrupt the running pipeline fiber or invalidate the lease fence; old agent keeps mutating; with `maxConcurrent=1` the new lane queues behind it. Spec §7.6 wants a hard supersede (track + interrupt the in-flight fiber; bump the lease). + +8. **Lease is advisory only** — `acquire` always succeeds (fence bump; `WorktreeLeaseService.ts:24-62`); nothing consults `isValid` before the agent writes, so stale/superseded writers aren't blocked. Either gate worktree mutations on validity or document it as advisory + rely on hard interrupt (#7). + +9. **Lease leak on pre-dispatch failure** — `acquire` at `RealStepExecutor.ts:58`, but `ensuring(release)` wraps only the dispatch gen (line 90); a failure in `captureStep(pre)` (line 72) leaks the lease. **Fix:** wrap the whole post-acquire body in `ensuring(release)`. + +10. **Pipeline errors swallowed** — `runPipeline` does `Effect.catch(() => Effect.void)` (`WorkflowEngine.ts:206`); failing pipelines vanish, leaving tickets stuck `running`. **Fix:** emit `TicketBlocked`/`StepFailed` + log on pipeline error. + +11. **`awaitTerminal` busy-polls, no timeout** (`ProviderDispatchOutbox.ts:100-106`) — root of #1/#2; add max duration + interruptibility. + +12. **Full-log scans on hot paths** — `pendingWaitFor` (`WorkflowEngine.ts:63-84`), `DurableApprovalResume`, recovery all `store.readAll()` per call (O(all events)). Query by `stepRunId`/projection. + +## LOW + +- Post-step checkpoint captured after lease release (`RealStepExecutor.ts:90-91`) — ordering smudge. +- `runtimeMode: "full-access"` for all dispatched agents (`ProviderDispatchOutbox.ts:175`) — make it a conscious per-board choice. +- `workflow:operate` is standard-tier (`auth.ts AuthStandardClientScopes`) — consistent with `orchestration:operate`; noting only. + +## Required real-path tests (write FIRST, against the REAL executor — these would have caught #1–#6) + +- **2-step pipeline in a temp git repo:** create a ticket whose lane pipeline has two agent steps; assert both run in the SAME worktree and the 2nd does not fail on worktree/branch creation; assert accumulated diff reflects both. +- **Agent-question pause/resume:** drive a provider that parks a turn awaiting user input; assert the ticket becomes `waiting_on_user` (not stuck `running`), then `resolveApproval`/answer resumes the turn to terminal and the pipeline continues. +- **Restart with a non-terminal turn:** start a step, simulate restart (rebuild layers) while the turn is non-terminal and the session is gone; assert `recover()` completes promptly (no hang), re-dispatches or fails cleanly, and does NOT start a duplicate thread. +- **Approval across restart:** park on an `approval` step, restart, `resolveApproval`; assert the pipeline actually continues and routes. + +Use real `GitWorkflowService` + a temp repo (mirror `apps/server/src/checkpointing/Layers/CheckpointStore.test.ts` `initRepoWithCommit`), and a higher-fidelity provider double that can park on a question and that does NOT auto-complete inside recovery. diff --git a/docs/superpowers/reviews/codex-fix-prompt.txt b/docs/superpowers/reviews/codex-fix-prompt.txt new file mode 100644 index 00000000000..d40a4bbb6e1 --- /dev/null +++ b/docs/superpowers/reviews/codex-fix-prompt.txt @@ -0,0 +1,27 @@ +Fix the bugs found in the adversarial review of "Workflow Boards v1" in the t3code monorepo. Build is GREEN (vp typecheck + all workflow unit tests pass) but every test uses stubs/mocks, so the real end-to-end path is broken. Work autonomously; stop only on a real blocker. + +Repo: /Users/chris/Developer/t3code, branch ft/hyperion (stay on it, no PRs). pnpm, Effect 4 beta, TypeScript; tooling is `vp`. + +READ FIRST: docs/superpowers/reviews/2026-06-07-workflow-boards-v1-adversarial-review.md -- it lists every finding with file:line, why it breaks, and a fix direction, plus the required real-path tests. Also read the v1 spec docs/superpowers/specs/2026-06-06-workflow-boards-v1-design.md for the invariants. + +Method (mandatory, per finding): +1. FIRST write a real-path test that REPRODUCES the bug (fails for the right reason) -- against the REAL executor/services, NOT stubs. Use real GitWorkflowService + a temp git repo (mirror apps/server/src/checkpointing/Layers/CheckpointStore.test.ts initRepoWithCommit), and a higher-fidelity provider double that can park a turn on a question and does NOT auto-complete inside recovery. +2. Implement the fix so the test passes. +3. Run the test (pass) + the existing workflow suite (still green): `cd apps/server && pnpm exec vp test run src/workflow`. +4. Commit (one focused commit per finding, clear message). + +Start by writing the 4 "Required real-path tests" from the review (2-step pipeline in a temp repo; agent-question pause/resume; restart with a non-terminal turn; approval across restart). These must FAIL initially -- they encode findings #1-#6. Then fix in priority order: CRITICAL (1) -> HIGH (2,3,4,5,6) -> MEDIUM (7-12). LOW items optional. + +Hard rules: +- The bugs are in REAL wiring (RealStepExecutor, ProviderDispatchOutbox, TurnStateReader, WorkflowRecovery, DurableApprovalResume, WorkflowEngine, WorktreeLeaseService, WorktreePort). Do not "fix" by editing stubs/mocks to hide the gap. Never weaken/skip an existing test. +- Codebase is the source of truth: confirm real signatures by reading the actual files (ProviderService, ProjectionTurnRepository + projection_pending_approvals, GitWorkflowService, TerminalManager). Never invent APIs. +- awaitTerminal must be timeout-bounded + interruptible. Recovery must not block startup and must not hang on a dead-session turn. ensureWorktree must be idempotent and use the ticket's project repoRoot, not process.cwd(). File instructions must be resolved to text. awaiting_user must be produced for real agent questions and resumable across restart. Approval steps must actually resume the pipeline after restart. +- Scope: only fix the reviewed findings + add the missing real-path tests; no new features, no v2 work. + +Verify per milestone of work: `pnpm exec vp run typecheck`, `pnpm exec vp check` (fix via `pnpm exec vp fmt `), `cd apps/server && pnpm exec vp test run src/workflow`, plus `pnpm --filter @t3tools/contracts test` if contracts changed. If vp is missing, run `pnpm install` first. + +Keep going through all CRITICAL+HIGH+MEDIUM findings. Stop only if a needed API truly doesn't exist/contradicts the review unresolvably, or a fix can't be verified without guessing -- then report the exact blocker, file/symbol, what you tried, and 1-2 options. After each finding, post a 1-2 line summary (test added / fix / verified) and continue. + +Done when: all 4 real-path tests pass; CRITICAL+HIGH+MEDIUM findings fixed; vp typecheck + vp check pass; the full workflow suite is green; and the real end-to-end flow (2-step agent pipeline with a question, in a project worktree, surviving restart) works without stubs. + +Begin by reading the review doc, then writing the first failing real-path test. \ No newline at end of file diff --git a/docs/superpowers/reviews/codex-round4-prompt.txt b/docs/superpowers/reviews/codex-round4-prompt.txt new file mode 100644 index 00000000000..4f806de25c9 --- /dev/null +++ b/docs/superpowers/reviews/codex-round4-prompt.txt @@ -0,0 +1,23 @@ +Polish pass for "Workflow Boards v1" in the t3code monorepo. The core flows are correct and well-tested (3 review rounds done); these are LOW-severity cleanups only. DO NOT refactor working code or add features. Keep all existing tests green. Work autonomously; stop only on a real blocker. + +Repo: /Users/chris/Developer/t3code, branch ft/hyperion (stay on it, no PRs). pnpm, Effect 4 beta, TypeScript; tooling is `vp`. Per fix: write a failing test first (against real wiring, not stubs), implement, run, commit (one focused commit each). + +Fix these, in order: + +1. (REQUIRED) Silent pipeline errors. In apps/server/src/workflow/Layers/WorkflowEngine.ts, `runPipeline` ends with `.pipe(Effect.catch(() => Effect.void))`, so an orchestration-level (non-step) pipeline error vanishes and leaves the ticket stuck `running`. Fix: on such an error, commit a `TicketBlocked` event (reason includes the error) and log it; do not swallow silently. Step-level failures already route via StepFailed -- only the outer orchestration-error path changes. Test: force a pipeline-orchestration error (e.g. a committer/store failure injected via a layer) and assert a `TicketBlocked` event is recorded rather than a silently-stuck ticket. + +2. (REQUIRED) Lease-leak window in apps/server/src/workflow/Layers/RealStepExecutor.ts. `lease.acquire` runs before the instruction-file read and before the `Effect.ensuring(releaseIfStillOwner)` scope, so if the `{file}` instruction read fails the lease leaks. Fix: either move `lease.acquire` to AFTER the instruction is resolved, or bring the instruction read inside the `ensuring(releaseIfStillOwner)` scope so the lease is always released on failure. Test: a `{file}` instruction whose file does not exist -> step fails AND the worktree lease is released (no leaked row / next acquire sees a released lease). + +3. (OPTIONAL, only if low-risk) Recovery continuations bypass concurrency + interruption. In WorkflowRecovery.ts `monitorStartedDispatches`, recovered pipeline continuations are `forkDetach`ed straight into `completeRecoveredStep -> completePipelineFrom`, bypassing the per-board concurrency semaphore and not registered in the engine's `runningPipelines` map (so a manual drag during the recovery window can't interrupt them). If you can route recovered continuations through the same tracked, semaphore-gated start path the live engine uses WITHOUT destabilizing the passing restart tests, do so; otherwise leave a short code comment documenting the limitation and skip. Do not weaken the existing recovery tests. + +4. (OPTIONAL, only if low-risk) Test seam. The real-path tests stub `TurnProjectionPort`, so `TurnProjectionPortLive <-> ProjectionTurnRepository` is never exercised end-to-end. Add ONE focused test that drives a real orchestration turn projection (via ProjectionTurnRepository / the real turn events) and asserts `TurnStateReader.read` maps running/completed/error correctly through `TurnProjectionPortLive`. Do not change production code for this. + +Hard rules: do not touch v2-deferred scope; do not weaken/skip any test; confirm real signatures by reading the actual files; never invent APIs. The advisory-lease behavior is acceptable as-is given drag-interrupt -- do NOT add filesystem write-gating. + +Verify after each fix: `cd apps/server && pnpm exec vp test run src/workflow`, then at the end `pnpm exec vp run typecheck` and `pnpm exec vp check` (fix formatting with `pnpm exec vp fmt `). If vp is missing, run `pnpm install` first. + +Stop only if a fix can't be verified without guessing or destabilizes a passing test -- then report the exact blocker, file/symbol, what you tried, and 1-2 options. After each fix, post a 1-line summary (test / fix / verified) and continue. + +Done when: items 1-2 fixed with tests; items 3-4 done if low-risk (else documented); typecheck + check pass; the full workflow suite is green. + +Begin with item 1. \ No newline at end of file From c1deb82da1f37e17cdc60f1a0ccac8ef2fa1a236 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:43:00 -0400 Subject: [PATCH 062/295] docs: add codex build prompt for board-creation UX --- docs/superpowers/plans/codex-board-ux-prompt.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/codex-board-ux-prompt.txt b/docs/superpowers/plans/codex-board-ux-prompt.txt index 0fe9e374762..27b5ef64e79 100644 --- a/docs/superpowers/plans/codex-board-ux-prompt.txt +++ b/docs/superpowers/plans/codex-board-ux-prompt.txt @@ -23,7 +23,7 @@ Invariants (do not regress): - "Add board" mirrors "New thread": grouped project (memberProjects.length>1) picks the concrete member first. - Keep all v1 ticket/board RUNTIME behavior untouched; this is creation/listing/navigation only. -Verify: per task run that test (cd apps/server && pnpm exec vp test run src/; or pnpm --filter @t3tools/contracts test; or cd apps/web && pnpm exec vp test run src/). At task boundaries + end: `pnpm exec vp run typecheck`, `pnpm exec vp check` (fix via `pnpm exec vp fmt `), suites green. If vp missing, `pnpm install` first. +Verify: per task run that test (`cd apps/server && pnpm exec vp test run src/`, or `pnpm --filter @t3tools/contracts test`, or `cd apps/web && pnpm exec vp test run src/`). At task boundaries + end: `vp run typecheck`, `vp check` (fix via `vp fmt `), suites green. If vp missing, `pnpm install` first. Keep going through all 14 tasks. Stop only if a needed API truly doesn't exist/contradicts the plan unresolvably, or a test can't pass without guessing -- then report the exact blocker, file/symbol, what you tried, and 1-2 options. After each task, post a 1-line summary and continue. From df22d230b88111e4ebf86da16d26e5c6a44106eb Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:43:14 -0400 Subject: [PATCH 063/295] docs: trim codex board-ux prompt under 4000 chars --- docs/superpowers/plans/codex-board-ux-prompt.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/codex-board-ux-prompt.txt b/docs/superpowers/plans/codex-board-ux-prompt.txt index 27b5ef64e79..4bb011eeb37 100644 --- a/docs/superpowers/plans/codex-board-ux-prompt.txt +++ b/docs/superpowers/plans/codex-board-ux-prompt.txt @@ -27,6 +27,6 @@ Verify: per task run that test (`cd apps/server && pnpm exec vp test run src/.json (recent agent baked in), the board appears as a sidebar row and opens directly with no Register step; registerBoardFromFile is gone. +Done when: 14 tasks complete; typecheck + check pass; contracts/server/web workflow suites green; "Add board" one-click creates a templated .t3/boards/.json (recent agent baked in), the board appears as a sidebar row and opens with no Register step; registerBoardFromFile is gone. Begin with Task 1. From ac514ee2f94a2a4b429656cdacf3ed755bf48813 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:43:23 -0400 Subject: [PATCH 064/295] docs: board-ux prompt under 4000 chars --- docs/superpowers/plans/codex-board-ux-prompt.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/codex-board-ux-prompt.txt b/docs/superpowers/plans/codex-board-ux-prompt.txt index 4bb011eeb37..bdd7163b1ae 100644 --- a/docs/superpowers/plans/codex-board-ux-prompt.txt +++ b/docs/superpowers/plans/codex-board-ux-prompt.txt @@ -6,7 +6,7 @@ READ FIRST, in full: - docs/superpowers/plans/2026-06-07-board-creation-ux.md (14 tasks, exact code/steps) - docs/superpowers/specs/2026-06-07-board-creation-ux-design.md (invariants) -Feature: one-click "Add board" per project -> file-backed boards (.t3/boards/.json) auto-discovered + registered server-side, listed in the sidebar like chats (SquareKanban icon), defaulting to the user's most recent agent. Builds on Workflow Boards v1 under apps/server/src/workflow/**. +Feature: one-click "Add board" per project -> file-backed boards (.t3/boards/.json) auto-discovered + registered server-side, listed in the sidebar like chats, defaulting to the user's most recent agent. Builds on Workflow Boards v1 under apps/server/src/workflow/**. Per task (TDD, mandatory): write the failing test from the plan -> run, confirm it FAILS for the right reason -> minimal impl -> run, confirm PASS -> commit with the plan's message (one commit per task). Never impl before its test; never finish without a passing test; never skip/weaken a test. Do tasks 1->14 in order. From e0d06a984fb69ed8ab3367f0fae55d435a0f5e30 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:48:49 -0400 Subject: [PATCH 065/295] feat(workflow): default board template Constraint: Board Creation UX plan requires the default board template before create/discovery wiring.\nRejected: Hand-authoring ad hoc test fixtures | the default board should be generated by the same helper createBoard will use.\nConfidence: high\nScope-risk: narrow\nDirective: Keep this template pure; provider validation belongs to loader/discovery layers.\nTested: cd apps/server && pnpm exec vp test run src/workflow/defaultBoard.test.ts; pnpm exec vp run typecheck; pnpm exec vp check; cd apps/server && pnpm exec vp test run src/workflow\nNot-tested: Full app manual create-board flow is not implemented yet. --- apps/server/src/workflow/defaultBoard.test.ts | 39 ++++++++++++++ apps/server/src/workflow/defaultBoard.ts | 53 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 apps/server/src/workflow/defaultBoard.test.ts create mode 100644 apps/server/src/workflow/defaultBoard.ts diff --git a/apps/server/src/workflow/defaultBoard.test.ts b/apps/server/src/workflow/defaultBoard.test.ts new file mode 100644 index 00000000000..d6722e78a3b --- /dev/null +++ b/apps/server/src/workflow/defaultBoard.test.ts @@ -0,0 +1,39 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import { defaultBoardDefinition } from "./defaultBoard.ts"; +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); + +describe("defaultBoardDefinition", () => { + const def = defaultBoardDefinition({ + name: "My board", + agent: { instance: "codex", model: "gpt-5.4" }, + }); + + it("decodes as a valid WorkflowDefinition", () => + Effect.gen(function* () { + const decoded = yield* decodeWorkflowDefinition(def); + assert.equal(decoded.name, "My board"); + assert.equal(decoded.lanes.find((lane) => lane.key === "implement")?.pipeline?.length, 2); + }).pipe(Effect.runPromise)); + + it("passes the linter for a known agent instance", () => { + const errors = lintWorkflowDefinition(def as never, { + providerInstanceExists: (id) => id === "codex", + instructionFileExists: () => true, + }); + assert.deepEqual(errors, []); + }); + + it("bakes the agent into both steps", () => { + const steps = (def.lanes.find((lane) => lane.key === "implement")?.pipeline ?? + []) as ReadonlyArray<{ + agent?: { instance: string; model: string }; + }>; + assert.equal(steps[0]?.agent?.instance, "codex"); + assert.equal(steps[1]?.agent?.model, "gpt-5.4"); + }); +}); diff --git a/apps/server/src/workflow/defaultBoard.ts b/apps/server/src/workflow/defaultBoard.ts new file mode 100644 index 00000000000..f9b65fafcea --- /dev/null +++ b/apps/server/src/workflow/defaultBoard.ts @@ -0,0 +1,53 @@ +import type { WorkflowDefinition } from "@t3tools/contracts"; + +export interface DefaultBoardAgent { + readonly instance: string; + readonly model: string; +} + +export const defaultBoardDefinition = (input: { + readonly name: string; + readonly agent: DefaultBoardAgent; +}): WorkflowDefinition => + ({ + name: input.name, + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { + instance: input.agent.instance, + model: input.agent.model, + }, + instruction: + "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence.", + }, + { + key: "review", + type: "agent", + agent: { + instance: input.agent.instance, + model: input.agent.model, + }, + instruction: + "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship.", + }, + ], + on: { + success: "owner_review", + failure: "needs_attention", + blocked: "needs_attention", + }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "needs_attention", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }) as unknown as WorkflowDefinition; From fba89a0451431f206163d11f4c46f163f13f206f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:50:20 -0400 Subject: [PATCH 066/295] feat(workflow): board slug helpers Constraint: Board creation needs deterministic, collision-safe file slugs before createBoard writes files.\nRejected: Inline slug logic in RPC handlers | shared pure helpers keep discovery and createBoard aligned.\nConfidence: high\nScope-risk: narrow\nDirective: Keep slug generation deterministic; idempotent discovery depends on it.\nTested: cd apps/server && pnpm exec vp test run src/workflow/boardSlug.test.ts; pnpm exec vp run typecheck; pnpm exec vp check; cd apps/server && pnpm exec vp test run src/workflow\nNot-tested: createBoard slug collision flow is not wired yet. --- apps/server/src/workflow/boardSlug.test.ts | 16 ++++++++++++++++ apps/server/src/workflow/boardSlug.ts | 15 +++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 apps/server/src/workflow/boardSlug.test.ts create mode 100644 apps/server/src/workflow/boardSlug.ts diff --git a/apps/server/src/workflow/boardSlug.test.ts b/apps/server/src/workflow/boardSlug.test.ts new file mode 100644 index 00000000000..34e7a0b9b3e --- /dev/null +++ b/apps/server/src/workflow/boardSlug.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "@effect/vitest"; +import { slugifyBoardName, uniqueBoardSlug } from "./boardSlug.ts"; + +describe("boardSlug", () => { + it("slugifies names", () => { + assert.equal(slugifyBoardName("Workflow Board"), "workflow-board"); + assert.equal(slugifyBoardName(" A/B board!! "), "a-b-board"); + assert.equal(slugifyBoardName("!!!"), "board"); + }); + + it("uniquifies against existing slugs", () => { + const existing = new Set(["workflow-board", "workflow-board-2"]); + assert.equal(uniqueBoardSlug("workflow-board", existing), "workflow-board-3"); + assert.equal(uniqueBoardSlug("fresh", existing), "fresh"); + }); +}); diff --git a/apps/server/src/workflow/boardSlug.ts b/apps/server/src/workflow/boardSlug.ts new file mode 100644 index 00000000000..07d999fe857 --- /dev/null +++ b/apps/server/src/workflow/boardSlug.ts @@ -0,0 +1,15 @@ +export const slugifyBoardName = (name: string): string => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "board"; +}; + +export const uniqueBoardSlug = (base: string, existing: ReadonlySet): string => { + if (!existing.has(base)) return base; + let n = 2; + while (existing.has(`${base}-${n}`)) n += 1; + return `${base}-${n}`; +}; From eb1a03205723141d1467e073328aa5294c82d316 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:54:10 -0400 Subject: [PATCH 067/295] feat(workflow): board list/create RPC contracts; drop registerBoardFromFile Constraint: New workflow RPC entries must be in WsRpcGroup now, but the plan keeps the legacy register handler alive until Task 8 for the loader-path transition.\nRejected: Removing registerBoardFromFile handlers in Task 3 | Task 5 and Task 8 explicitly sequence that removal later.\nConfidence: medium\nScope-risk: moderate\nDirective: Replace the temporary listBoards/createBoard handler failures with real BoardDiscovery/createBoard wiring in Task 8, then remove registerBoardFromFile everywhere.\nTested: pnpm --filter @t3tools/contracts test -- workflow.test.ts; pnpm exec vp run typecheck; pnpm exec vp check; cd apps/server && pnpm exec vp test run src/workflow; pnpm --filter @t3tools/contracts test\nNot-tested: listBoards/createBoard runtime behavior is intentionally not implemented until later tasks. --- .../workflow/Layers/WorkflowRpcHandlers.ts | 21 ++++++++++++++ packages/contracts/src/rpc.ts | 20 +++++++++++++ packages/contracts/src/workflow.test.ts | 28 +++++++++++++++++++ packages/contracts/src/workflow.ts | 19 ++++++++++++- packages/contracts/src/workflowRpc.test.ts | 1 + 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts index 9aabd05247c..7a6b06f54de 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -1,9 +1,11 @@ import type { + AgentSelection, BoardId, BoardSnapshot, BoardTicketView, EnvironmentAuthorizationError, LaneKey, + ProjectId, StepRunId, StepRunStatus, TicketId, @@ -110,6 +112,7 @@ const boardSnapshot = ( .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow tickets", cause))); return { + projectId: board.projectId as ProjectId, board: { boardId, name: board.name, @@ -158,6 +161,24 @@ export const workflowRpcHandlers = (deps: WorkflowRpcHandlerDeps) => ({ deps.fileLoader.loadAndRegister(input).pipe(Effect.map((boardId) => ({ boardId }))), { "rpc.aggregate": "workflow" }, ), + [WORKFLOW_WS_METHODS.listBoards]: (input: { readonly projectId: ProjectId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listBoards, + Effect.fail( + workflowRpcError(`workflow.listBoards is not implemented for ${input.projectId}`), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.createBoard]: (input: { + readonly projectId: ProjectId; + readonly name: string; + readonly agent: AgentSelection; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createBoard, + Effect.fail(workflowRpcError(`workflow.createBoard is not implemented for ${input.name}`)), + { "rpc.aggregate": "workflow" }, + ), [WORKFLOW_WS_METHODS.getBoard]: (input: { readonly boardId: BoardId }) => deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoard, boardSnapshot(deps, input.boardId), { "rpc.aggregate": "workflow", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9fbd03a64c3..249a11a6da0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -117,7 +117,9 @@ import { } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; import { + AgentSelection, BoardId, + BoardListEntry, BoardSnapshot, BoardStreamItem, LaneKey, @@ -537,6 +539,22 @@ export const WsWorkflowRegisterBoardFromFileRpc = Rpc.make( }, ); +export const WsWorkflowListBoardsRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoards, { + payload: Schema.Struct({ projectId: ProjectId }), + success: Schema.Array(BoardListEntry), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowCreateBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.createBoard, { + payload: Schema.Struct({ + projectId: ProjectId, + name: Schema.String, + agent: AgentSelection, + }), + success: Schema.Struct({ boardId: BoardId, snapshot: BoardSnapshot }), + error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), +}); + export const WsWorkflowGetBoardRpc = Rpc.make(WORKFLOW_WS_METHODS.getBoard, { payload: Schema.Struct({ boardId: BoardId }), success: BoardSnapshot, @@ -681,6 +699,8 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, WsWorkflowRegisterBoardFromFileRpc, + WsWorkflowListBoardsRpc, + WsWorkflowCreateBoardRpc, WsWorkflowGetBoardRpc, WsWorkflowSubscribeBoardRpc, WsWorkflowCreateTicketRpc, diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts index b87c9eaf8f1..078478632d2 100644 --- a/packages/contracts/src/workflow.test.ts +++ b/packages/contracts/src/workflow.test.ts @@ -3,12 +3,15 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + BoardListEntry, BoardId, + BoardSnapshot, LaneEntryToken, TicketId, WorkflowDefinition, WorkflowEvent, WorkflowEventId, + WORKFLOW_WS_METHODS, } from "./workflow.ts"; const decodeTicketId = Schema.decodeUnknownEffect(TicketId); @@ -125,3 +128,28 @@ describe("WorkflowEvent", () => { }), ); }); + +describe("board creation contracts", () => { + const decodeBoardListEntry = Schema.decodeUnknownEffect(BoardListEntry); + + it.effect("decodes a BoardListEntry", () => + Effect.gen(function* () { + const entry = yield* decodeBoardListEntry({ + boardId: "p1__board", + name: "Board", + filePath: ".t3/boards/board.json", + error: null, + }); + assert.equal(entry.error, null); + }), + ); + + it("BoardSnapshot carries projectId", () => { + assert.isTrue(Object.keys(BoardSnapshot.fields).includes("projectId")); + }); + + it("exposes the new methods", () => { + assert.equal(WORKFLOW_WS_METHODS.listBoards, "workflow.listBoards"); + assert.equal(WORKFLOW_WS_METHODS.createBoard, "workflow.createBoard"); + }); +}); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index ebe23fab4d3..083ca1c5a7a 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -1,9 +1,17 @@ import * as Schema from "effect/Schema"; -import { ApprovalRequestId, IsoDateTime, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + ApprovalRequestId, + IsoDateTime, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; export const WORKFLOW_WS_METHODS = { registerBoardFromFile: "workflow.registerBoardFromFile", + listBoards: "workflow.listBoards", + createBoard: "workflow.createBoard", getBoard: "workflow.getBoard", subscribeBoard: "workflow.subscribeBoard", createTicket: "workflow.createTicket", @@ -280,6 +288,7 @@ export const BoardTicketView = Schema.Struct({ export type BoardTicketView = typeof BoardTicketView.Type; export const BoardSnapshot = Schema.Struct({ + projectId: ProjectId, board: Schema.Struct({ boardId: BoardId, name: Schema.String, @@ -296,6 +305,14 @@ export const BoardSnapshot = Schema.Struct({ }); export type BoardSnapshot = typeof BoardSnapshot.Type; +export const BoardListEntry = Schema.Struct({ + boardId: BoardId, + name: Schema.String, + filePath: Schema.String, + error: Schema.NullOr(Schema.String), +}); +export type BoardListEntry = typeof BoardListEntry.Type; + export const BoardStreamItem = Schema.Union([ Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: BoardSnapshot }), Schema.Struct({ kind: Schema.Literal("ticket"), ticket: BoardTicketView }), diff --git a/packages/contracts/src/workflowRpc.test.ts b/packages/contracts/src/workflowRpc.test.ts index 5ee7d8d96e6..52e34d89841 100644 --- a/packages/contracts/src/workflowRpc.test.ts +++ b/packages/contracts/src/workflowRpc.test.ts @@ -32,6 +32,7 @@ describe("workflow RPC contracts", () => { const item = yield* decodeBoardStreamItem({ kind: "snapshot", snapshot: { + projectId: "project-1", board: { boardId: "board-1", name: "Delivery", From bdca868135526a08d47b6d080a5964daa111f423 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 15:57:08 -0400 Subject: [PATCH 068/295] feat(workflow): board unregister + read-model board delete/list Constraint: Discovery must unregister deleted board files and list boards per project from projection rows.\nRejected: Keeping stale projection rows on delete | deleted files must disappear from listBoards results.\nConfidence: high\nScope-risk: narrow\nDirective: Do not cascade into ticket runtime state from deleteBoard; this method removes board-list projection rows only.\nTested: cd apps/server && pnpm exec vp test run src/workflow/Layers/BoardRegistry.test.ts src/workflow/Layers/WorkflowReadModel.test.ts; pnpm exec vp run typecheck; cd apps/server && pnpm exec vp test run src/workflow; pnpm exec vp check\nNot-tested: File-discovery delete flow is not wired until Task 7. --- .../src/workflow/Layers/BoardRegistry.test.ts | 9 +++++++ .../src/workflow/Layers/BoardRegistry.ts | 9 ++++++- .../Layers/DurableApprovalResume.test.ts | 3 +++ .../Layers/WorkflowEngine.integration.test.ts | 1 + .../workflow/Layers/WorkflowReadModel.test.ts | 21 +++++++++++++++ .../src/workflow/Layers/WorkflowReadModel.ts | 27 ++++++++++++++++++- .../Layers/WorkflowRpcHandlers.test.ts | 3 +++ .../src/workflow/Services/BoardRegistry.ts | 1 + .../workflow/Services/WorkflowReadModel.ts | 10 +++++++ 9 files changed, 82 insertions(+), 2 deletions(-) diff --git a/apps/server/src/workflow/Layers/BoardRegistry.test.ts b/apps/server/src/workflow/Layers/BoardRegistry.test.ts index 6158fd759b6..aeaf1be1159 100644 --- a/apps/server/src/workflow/Layers/BoardRegistry.test.ts +++ b/apps/server/src/workflow/Layers/BoardRegistry.test.ts @@ -51,4 +51,13 @@ layer("BoardRegistry", (it) => { assert.equal(result._tag, "Failure"); }), ); + + it.effect("unregister removes a registered definition", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-3" as never, def); + yield* registry.unregister("b-3" as never); + assert.isNull(yield* registry.getDefinition("b-3" as never)); + }), + ); }); diff --git a/apps/server/src/workflow/Layers/BoardRegistry.ts b/apps/server/src/workflow/Layers/BoardRegistry.ts index 6306344c520..efa9975cb3e 100644 --- a/apps/server/src/workflow/Layers/BoardRegistry.ts +++ b/apps/server/src/workflow/Layers/BoardRegistry.ts @@ -40,12 +40,19 @@ const make = Effect.gen(function* () { const getDefinition: BoardRegistryShape["getDefinition"] = (boardId) => Ref.get(store).pipe(Effect.map((current) => current.get(boardId as string) ?? null)); + const unregister: BoardRegistryShape["unregister"] = (boardId) => + Ref.update(store, (current) => { + const next = new Map(current); + next.delete(boardId as string); + return next; + }); + const getLane: BoardRegistryShape["getLane"] = (boardId, laneKey) => getDefinition(boardId).pipe( Effect.map((definition) => definition?.lanes.find((lane) => lane.key === laneKey) ?? null), ); - return { register, getDefinition, getLane } satisfies BoardRegistryShape; + return { register, unregister, getDefinition, getLane } satisfies BoardRegistryShape; }); export const BoardRegistryLive = Layer.effect(BoardRegistry, make); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts index 9286f402117..b5b5edf10f6 100644 --- a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts @@ -80,6 +80,8 @@ it.effect("routes provider-question approval resolution to the provider response Layer.succeed(WorkflowReadModel, { registerBoard: () => Effect.void, getBoard: () => Effect.succeed(null), + deleteBoard: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), listTickets: () => Effect.succeed([]), getTicketDetail: () => Effect.succeed(null), }), @@ -87,6 +89,7 @@ it.effect("routes provider-question approval resolution to the provider response Layer.provideMerge( Layer.succeed(BoardRegistry, { register: () => Effect.die("unused"), + unregister: () => Effect.void, getDefinition: () => Effect.succeed(null), getLane: () => Effect.succeed(null), }), diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index 8efb72b0a62..1c823bc84aa 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -176,6 +176,7 @@ explodingLayer("WorkflowEngine pipeline error handling", (it) => { const failingDefinitionRegistry = Layer.succeed(BoardRegistry, { register: () => Effect.succeed(definition as never), + unregister: () => Effect.void, getLane: (_boardId, laneKey) => Effect.succeed((definition.lanes.find((lane) => lane.key === laneKey) ?? null) as never), getDefinition: () => Effect.die("definition unavailable"), diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts index 3f4c36e85f7..48820c2329c 100644 --- a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts @@ -90,4 +90,25 @@ layer("WorkflowReadModel", (it) => { assert.equal(detail?.steps[0]?.stepKey, "code"); }), ); + + it.effect("lists boards for a project and deletes one", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: "p1__a" as never, + projectId: "p1" as never, + name: "A", + workflowFilePath: ".t3/boards/a.json", + workflowVersionHash: "h", + maxConcurrentTickets: 3, + }); + + const before = yield* read.listBoardsForProject("p1" as never); + assert.equal(before.length, 1); + assert.equal(before[0]?.filePath, ".t3/boards/a.json"); + + yield* read.deleteBoard("p1__a" as never); + assert.deepEqual(yield* read.listBoardsForProject("p1" as never), []); + }), + ); }); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts index 64f513933e7..c9ad9cbef50 100644 --- a/apps/server/src/workflow/Layers/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -6,6 +6,7 @@ import type { SqlError } from "effect/unstable/sql/SqlError"; import { WorkflowEventStoreError } from "../Services/Errors.ts"; import { WorkflowReadModel, + type BoardListRow, type BoardRow, type StepRunRow, type TicketRow, @@ -60,6 +61,23 @@ const make = Effect.gen(function* () { WHERE board_id = ${boardId} `).pipe(Effect.map((rows) => rows[0] ?? null)); + const deleteBoard: WorkflowReadModelShape["deleteBoard"] = (boardId) => + wrap(sql` + DELETE FROM projection_board + WHERE board_id = ${boardId} + `).pipe(Effect.asVoid); + + const listBoardsForProject: WorkflowReadModelShape["listBoardsForProject"] = (projectId) => + wrap(sql` + SELECT + board_id AS "boardId", + name, + workflow_file_path AS "filePath" + FROM projection_board + WHERE project_id = ${projectId} + ORDER BY name COLLATE NOCASE ASC, board_id ASC + `); + const listTickets: WorkflowReadModelShape["listTickets"] = (boardId) => wrap(sql` SELECT @@ -106,7 +124,14 @@ const make = Effect.gen(function* () { return { ticket, steps }; }); - return { registerBoard, getBoard, listTickets, getTicketDetail } satisfies WorkflowReadModelShape; + return { + registerBoard, + getBoard, + deleteBoard, + listBoardsForProject, + listTickets, + getTicketDetail, + } satisfies WorkflowReadModelShape; }); export const WorkflowReadModelLive = Layer.effect(WorkflowReadModel, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts index c3bd320e2e6..3b5dd3e4835 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -51,9 +51,12 @@ it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => ]), getTicketDetail: () => Effect.succeed(null), registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), }, boardRegistry: { register: () => Effect.die("unused"), + unregister: () => Effect.void, getDefinition: () => Effect.succeed(definition), getLane: () => Effect.succeed(null), }, diff --git a/apps/server/src/workflow/Services/BoardRegistry.ts b/apps/server/src/workflow/Services/BoardRegistry.ts index 789397eabd9..ed7d6f02647 100644 --- a/apps/server/src/workflow/Services/BoardRegistry.ts +++ b/apps/server/src/workflow/Services/BoardRegistry.ts @@ -13,6 +13,7 @@ export interface BoardRegistryShape { boardId: BoardId, definition: unknown, ) => Effect.Effect; + readonly unregister: (boardId: BoardId) => Effect.Effect; readonly getDefinition: (boardId: BoardId) => Effect.Effect; readonly getLane: (boardId: BoardId, laneKey: LaneKey) => Effect.Effect; } diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts index 666c5c73bcf..dc131d475e5 100644 --- a/apps/server/src/workflow/Services/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -13,6 +13,12 @@ export interface BoardRow { readonly maxConcurrentTickets: number; } +export interface BoardListRow { + readonly boardId: string; + readonly name: string; + readonly filePath: string; +} + export interface TicketRow { readonly ticketId: string; readonly boardId: string; @@ -45,6 +51,10 @@ export interface WorkflowReadModelShape { readonly maxConcurrentTickets: number; }) => Effect.Effect; readonly getBoard: (boardId: BoardId) => Effect.Effect; + readonly deleteBoard: (boardId: BoardId) => Effect.Effect; + readonly listBoardsForProject: ( + projectId: ProjectId, + ) => Effect.Effect, WorkflowEventStoreError>; readonly listTickets: ( boardId: BoardId, ) => Effect.Effect, WorkflowEventStoreError>; From 1844a7c01d64e741604e7f2d573dd7f044dbdef0 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:01:22 -0400 Subject: [PATCH 069/295] refactor(workflow): split loader read path from persisted relative path Constraint: create/list discovery must resolve board files under a project workspace but persist workspace-relative paths.\nRejected: Continuing to store absolute or client-provided paths | sidebar board rows need portable relative file paths and server-side root resolution.\nConfidence: high\nScope-risk: moderate\nDirective: Task 8 should remove the temporary registerBoardFromFile shim after createBoard/listBoards are real.\nTested: cd apps/server && pnpm exec vp test run src/workflow/Layers/WorkflowFileLoader.test.ts; cd apps/server && pnpm exec vp test run src/workflow/Layers/WorkflowRpcHandlers.test.ts; pnpm exec vp run typecheck; cd apps/server && pnpm exec vp test run src/workflow; pnpm exec vp check\nNot-tested: BoardDiscovery scanning is not implemented until Task 7. --- .../Layers/WorkflowFileLoader.test.ts | 83 +++++++++++++++++-- .../src/workflow/Layers/WorkflowFileLoader.ts | 8 +- .../workflow/Layers/WorkflowRpcHandlers.ts | 9 +- .../workflow/Services/WorkflowFileLoader.ts | 4 +- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts index 62a4e199850..cf039da6579 100644 --- a/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts @@ -1,7 +1,13 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + import { assert, it } from "@effect/vitest"; -import type { BoardId, ProjectId } from "@t3tools/contracts"; +import { WorkflowRpcError, type BoardId, type ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { MigrationsLive } from "../../persistence/Migrations.ts"; @@ -73,8 +79,8 @@ mk((instanceId) => instanceId === "codex_main")("WorkflowFileLoader", (it) => { const loadedBoardId = yield* loader.loadAndRegister({ boardId, projectId: "project-loader" as ProjectId, - filePath: ".t3/boards/delivery.json", - repoRoot: "/repo", + workspaceRoot: "/repo", + relativePath: ".t3/boards/delivery.json", }); const definition = yield* registry.getDefinition(boardId); @@ -90,6 +96,73 @@ mk((instanceId) => instanceId === "codex_main")("WorkflowFileLoader", (it) => { ); }); +it.effect( + "WorkflowFileLoader reads from the workspace-root path and persists the relative path", + () => { + let workspaceRoot = ""; + return Effect.gen(function* () { + workspaceRoot = mkdtempSync(join(tmpdir(), "t3-workflow-loader-")); + const relativePath = ".t3/boards/split.json"; + const absolutePath = resolve(workspaceRoot, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, workflowJson(), "utf8"); + + const readPath = yield* Ref.make(null); + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: (filePath) => + Effect.gen(function* () { + yield* Ref.set(readPath, filePath); + return yield* Effect.try({ + try: () => readFileSync(filePath, "utf8"), + catch: (cause) => + new WorkflowRpcError({ message: "test workflow file read failed", cause }), + }); + }), + instructionFileExists: ({ repoRelativePath }) => + Effect.succeed(repoRelativePath === "prompts/implement.md"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const read = yield* WorkflowReadModel; + const boardId = "board-split-path" as BoardId; + + yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot, + relativePath, + }); + + assert.equal(yield* Ref.get(readPath), absolutePath); + const board = yield* read.getBoard(boardId); + assert.equal(board?.workflowFilePath, relativePath); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (workspaceRoot !== "") { + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }), + ), + ); + }, +); + mk(() => false)("WorkflowFileLoader lint failure", (it) => { it.effect("fails when the workflow references an unknown provider instance", () => Effect.gen(function* () { @@ -99,8 +172,8 @@ mk(() => false)("WorkflowFileLoader lint failure", (it) => { loader.loadAndRegister({ boardId: "board-loader-fail" as BoardId, projectId: "project-loader" as ProjectId, - filePath: ".t3/boards/delivery.json", - repoRoot: "/repo", + workspaceRoot: "/repo", + relativePath: ".t3/boards/delivery.json", }), ); diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts index 70e30e91965..2739ca81388 100644 --- a/apps/server/src/workflow/Layers/WorkflowFileLoader.ts +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts @@ -39,7 +39,9 @@ const make = Effect.gen(function* () { const loadAndRegister: WorkflowFileLoaderShape["loadAndRegister"] = (input) => Effect.gen(function* () { - const raw = yield* files.readFileString(input.filePath); + const raw = yield* files.readFileString( + path.resolve(input.workspaceRoot, input.relativePath), + ); const definition = yield* decodeWorkflowDefinitionJson(raw).pipe( Effect.mapError(toWorkflowRpcError("workflow file decode failed")), ); @@ -63,7 +65,7 @@ const make = Effect.gen(function* () { ), (repoRelativePath) => files - .instructionFileExists({ repoRoot: input.repoRoot, repoRelativePath }) + .instructionFileExists({ repoRoot: input.workspaceRoot, repoRelativePath }) .pipe(Effect.map((exists) => [repoRelativePath, exists] as const)), { concurrency: "unbounded" }, ); @@ -88,7 +90,7 @@ const make = Effect.gen(function* () { boardId: input.boardId, projectId: input.projectId, name: definition.name, - workflowFilePath: input.filePath, + workflowFilePath: input.relativePath, workflowVersionHash: sha256Hex(raw), maxConcurrentTickets: definition.settings?.maxConcurrentTickets ?? 3, }) diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts index 7a6b06f54de..413c1b5ef01 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -158,7 +158,14 @@ export const workflowRpcHandlers = (deps: WorkflowRpcHandlerDeps) => ({ }) => deps.observeRpcEffect( WORKFLOW_WS_METHODS.registerBoardFromFile, - deps.fileLoader.loadAndRegister(input).pipe(Effect.map((boardId) => ({ boardId }))), + deps.fileLoader + .loadAndRegister({ + boardId: input.boardId, + projectId: input.projectId, + workspaceRoot: input.repoRoot, + relativePath: input.filePath, + }) + .pipe(Effect.map((boardId) => ({ boardId }))), { "rpc.aggregate": "workflow" }, ), [WORKFLOW_WS_METHODS.listBoards]: (input: { readonly projectId: ProjectId }) => diff --git a/apps/server/src/workflow/Services/WorkflowFileLoader.ts b/apps/server/src/workflow/Services/WorkflowFileLoader.ts index 03d9d203730..e24ce9a2e79 100644 --- a/apps/server/src/workflow/Services/WorkflowFileLoader.ts +++ b/apps/server/src/workflow/Services/WorkflowFileLoader.ts @@ -28,8 +28,8 @@ export interface WorkflowFileLoaderShape { readonly loadAndRegister: (input: { readonly boardId: BoardId; readonly projectId: ProjectId; - readonly filePath: string; - readonly repoRoot: string; + readonly workspaceRoot: string; + readonly relativePath: string; }) => Effect.Effect; } From 06877b92ce90be0df4648da5a192c7aa8ab99d37 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:03:49 -0400 Subject: [PATCH 070/295] feat(workflow): project workspace-root resolver Constraint: Board creation must resolve workspace roots server-side from projectId, never from client paths.\nRejected: Passing repo paths from the web client | this preserves the invariant that server projections own workspace roots.\nConfidence: high\nScope-risk: narrow\nDirective: Use this port in BoardDiscovery and createBoard instead of accepting paths in RPC payloads.\nTested: cd apps/server && pnpm exec vp test run src/workflow/Layers/ProjectWorkspaceResolver.test.ts; pnpm exec vp run typecheck; cd apps/server && pnpm exec vp test run src/workflow; pnpm exec vp check\nNot-tested: Real projection SQL lookup is covered by existing ProjectionSnapshotQuery tests, not duplicated here. --- .../Layers/ProjectWorkspaceResolver.test.ts | 70 +++++++++++++++++++ .../Layers/ProjectWorkspaceResolver.ts | 37 ++++++++++ .../Services/ProjectWorkspaceResolver.ts | 21 ++++++ 3 files changed, 128 insertions(+) create mode 100644 apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts create mode 100644 apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts create mode 100644 apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts diff --git a/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts new file mode 100644 index 00000000000..cf018055cae --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import type { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProjectionSnapshotQueryShape } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectWorkspaceResolver, + ProjectWorkspaceResolverError, +} from "../Services/ProjectWorkspaceResolver.ts"; +import { ProjectWorkspaceResolverLive } from "./ProjectWorkspaceResolver.ts"; + +const projectId = "project-1" as ProjectId; + +const queryLayer = (getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"]) => + Layer.succeed(ProjectionSnapshotQuery, { + getProjectShellById, + } as unknown as ProjectionSnapshotQueryShape); + +it.effect("ProjectWorkspaceResolver resolves a project workspaceRoot", () => + Effect.gen(function* () { + const layer = ProjectWorkspaceResolverLive.pipe( + Layer.provide( + queryLayer(() => + Effect.succeed( + Option.some({ + id: projectId, + title: "Project", + workspaceRoot: "/tmp/t3-project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-07T00:00:00.000Z" as never, + updatedAt: "2026-06-07T00:00:00.000Z" as never, + }), + ), + ), + ), + ); + + const workspaceRoot = yield* Effect.gen(function* () { + const resolver = yield* ProjectWorkspaceResolver; + return yield* resolver.resolve(projectId); + }).pipe(Effect.provide(layer)); + + assert.equal(workspaceRoot, "/tmp/t3-project"); + }), +); + +it.effect("ProjectWorkspaceResolver fails with a typed error for an unknown project", () => + Effect.gen(function* () { + const layer = ProjectWorkspaceResolverLive.pipe( + Layer.provide(queryLayer(() => Effect.succeed(Option.none()))), + ); + + const result = yield* Effect.exit( + Effect.gen(function* () { + const resolver = yield* ProjectWorkspaceResolver; + return yield* resolver.resolve("missing-project" as ProjectId); + }).pipe(Effect.provide(layer)), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(String(result.cause).includes(ProjectWorkspaceResolverError.name)); + } + }), +); diff --git a/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts new file mode 100644 index 00000000000..c7f0769da3e --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectWorkspaceResolver, + ProjectWorkspaceResolverError, + type ProjectWorkspaceResolverShape, +} from "../Services/ProjectWorkspaceResolver.ts"; + +const toResolverError = (message: string) => (cause: unknown) => + new ProjectWorkspaceResolverError({ message, cause }); + +const make = Effect.gen(function* () { + const projects = yield* ProjectionSnapshotQuery; + + const resolve: ProjectWorkspaceResolverShape["resolve"] = (projectId) => + projects.getProjectShellById(projectId).pipe( + Effect.mapError(toResolverError(`Failed to resolve workspace for project ${projectId}`)), + Effect.flatMap((project) => + Option.match(project, { + onNone: () => + Effect.fail( + new ProjectWorkspaceResolverError({ + message: `Project ${projectId} was not found`, + }), + ), + onSome: (shell) => Effect.succeed(shell.workspaceRoot as string), + }), + ), + ); + + return { resolve } satisfies ProjectWorkspaceResolverShape; +}); + +export const ProjectWorkspaceResolverLive = Layer.effect(ProjectWorkspaceResolver, make); diff --git a/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts b/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts new file mode 100644 index 00000000000..7aec6ff1d00 --- /dev/null +++ b/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts @@ -0,0 +1,21 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ProjectWorkspaceResolverError extends Schema.TaggedErrorClass()( + "ProjectWorkspaceResolverError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export interface ProjectWorkspaceResolverShape { + readonly resolve: (projectId: ProjectId) => Effect.Effect; +} + +export class ProjectWorkspaceResolver extends Context.Service< + ProjectWorkspaceResolver, + ProjectWorkspaceResolverShape +>()("t3/workflow/Services/ProjectWorkspaceResolver") {} From ce28920f3df42dbd907d525c7cbbb47a0a842430 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:08:04 -0400 Subject: [PATCH 071/295] feat(workflow): BoardDiscovery (scan/register/unregister .t3/boards) Constraint: listBoards must discover file-backed boards on demand and unregister rows for files that disappear.\nRejected: Server push/watchers for board changes | v1 explicitly defers watchers and uses on-demand list refresh.\nConfidence: high\nScope-risk: moderate\nDirective: Keep discovery idempotent and keep invalid files as error entries without registering them.\nTested: cd apps/server && pnpm exec vp test run src/workflow/Layers/BoardDiscovery.test.ts; pnpm exec vp run typecheck; cd apps/server && pnpm exec vp test run src/workflow; pnpm exec vp check\nNot-tested: RPC list/create handlers are not wired until Task 8. --- .../workflow/Layers/BoardDiscovery.test.ts | 105 +++++++++++ .../src/workflow/Layers/BoardDiscovery.ts | 169 ++++++++++++++++++ .../src/workflow/Services/BoardDiscovery.ts | 17 ++ 3 files changed, 291 insertions(+) create mode 100644 apps/server/src/workflow/Layers/BoardDiscovery.test.ts create mode 100644 apps/server/src/workflow/Layers/BoardDiscovery.ts create mode 100644 apps/server/src/workflow/Services/BoardDiscovery.ts diff --git a/apps/server/src/workflow/Layers/BoardDiscovery.test.ts b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts new file mode 100644 index 00000000000..c4cbacf065e --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts @@ -0,0 +1,105 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardDiscovery } from "../Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowProviderInstancePort } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { BoardDiscoveryLive } from "./BoardDiscovery.ts"; +import { WorkflowFileLoaderLive, WorkflowFilePortLive } from "./WorkflowFileLoader.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const projectId = "project-discovery" as ProjectId; + +const boardFile = (name: string) => + JSON.stringify( + defaultBoardDefinition({ + name, + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + +it.layer(NodeServices.layer)("BoardDiscovery", (it) => { + it.effect("discovers boards, reports invalid files, and unregisters deleted files", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), boardFile("Alpha")); + yield* fs.writeFileString(path.join(boardsDir, "beta.json"), boardFile("Beta")); + yield* fs.writeFileString(path.join(boardsDir, "broken.json"), "{"); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const read = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + + const entries = yield* discovery.discover(projectId); + assert.equal(entries.length, 3); + assert.isTrue( + entries.some( + (entry) => + entry.boardId === `${projectId}__alpha` && + entry.filePath === ".t3/boards/alpha.json" && + entry.error === null, + ), + ); + assert.isTrue( + entries.some( + (entry) => entry.boardId === `${projectId}__broken` && entry.error !== null, + ), + ); + + const boards = yield* read.listBoardsForProject(projectId); + assert.deepEqual( + boards.map((board) => board.boardId), + [`${projectId}__alpha`, `${projectId}__beta`], + ); + + yield* fs.remove(path.join(boardsDir, "alpha.json")); + const afterDelete = yield* discovery.discover(projectId); + assert.isFalse(afterDelete.some((entry) => entry.boardId === `${projectId}__alpha`)); + assert.isNull(yield* registry.getDefinition(`${projectId}__alpha` as never)); + assert.deepEqual( + (yield* read.listBoardsForProject(projectId)).map((board) => board.boardId), + [`${projectId}__beta`], + ); + }).pipe(Effect.provide(layer)); + }), + ), + ); +}); diff --git a/apps/server/src/workflow/Layers/BoardDiscovery.ts b/apps/server/src/workflow/Layers/BoardDiscovery.ts new file mode 100644 index 00000000000..43fece202a2 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.ts @@ -0,0 +1,169 @@ +import { + BoardId, + WorkflowDefinition, + WorkflowRpcError, + type BoardListEntry, + type ProjectId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardDiscovery, type BoardDiscoveryShape } from "../Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + new WorkflowRpcError({ message, cause }); + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const isJsonBoardFile = (name: string) => name.endsWith(".json"); + +const boardSlugFromFileName = (fileName: string): string => fileName.slice(0, -".json".length); + +const boardIdFor = (projectId: ProjectId, slug: string) => BoardId.make(`${projectId}__${slug}`); + +const makeEntry = (input: { + readonly boardId: BoardId; + readonly name: string; + readonly relativePath: string; + readonly error: string | null; +}): BoardListEntry => ({ + boardId: input.boardId, + name: input.name, + filePath: input.relativePath, + error: input.error, +}); + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectWorkspaceResolver; + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + const cache = yield* Ref.make>>(new Map()); + + const discoverFile = (input: { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly fileName: string; + }) => { + const slug = boardSlugFromFileName(input.fileName); + const boardId = boardIdFor(input.projectId, slug); + const relativePath = `.t3/boards/${input.fileName}`; + const absolutePath = path.join(input.workspaceRoot, relativePath); + + return fileSystem.readFileString(absolutePath).pipe( + Effect.mapError(toWorkflowRpcError(`Failed to read workflow board ${relativePath}`)), + Effect.flatMap((raw) => + decodeWorkflowDefinitionJson(raw).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.succeed( + makeEntry({ + boardId, + name: slug, + relativePath, + error: errorMessage(cause), + }), + ), + onSuccess: (definition) => + loader + .loadAndRegister({ + boardId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + relativePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.succeed( + makeEntry({ + boardId, + name: definition.name, + relativePath, + error: errorMessage(cause), + }), + ), + onSuccess: () => + Effect.succeed( + makeEntry({ + boardId, + name: definition.name, + relativePath, + error: null, + }), + ), + }), + ), + }), + ), + ), + ); + }; + + const discover: BoardDiscoveryShape["discover"] = (projectId) => + Effect.gen(function* () { + const workspaceRoot = yield* resolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const exists = yield* fileSystem + .exists(boardsDir) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to check workflow boards directory"))); + const fileNames = exists + ? yield* fileSystem + .readDirectory(boardsDir) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow boards directory"))) + : []; + const entries = yield* Effect.forEach(fileNames.filter(isJsonBoardFile).sort(), (fileName) => + discoverFile({ projectId, workspaceRoot, fileName }), + ); + + const validBoardIds = new Set( + entries.filter((entry) => entry.error === null).map((entry) => entry.boardId as string), + ); + const cachedEntries = (yield* Ref.get(cache)).get(projectId as string) ?? []; + const removedBoardIds = cachedEntries + .map((entry) => entry.boardId) + .filter((boardId) => !validBoardIds.has(boardId as string)); + + yield* Effect.forEach( + removedBoardIds, + (boardId) => + registry + .unregister(boardId) + .pipe( + Effect.andThen(readModel.deleteBoard(boardId)), + Effect.mapError(toWorkflowRpcError("Failed to unregister workflow board")), + ), + { discard: true }, + ); + + yield* Ref.update(cache, (current) => new Map(current).set(projectId as string, entries)); + return entries; + }); + + const list: BoardDiscoveryShape["list"] = (projectId) => + Ref.get(cache).pipe( + Effect.flatMap((current) => { + const cached = current.get(projectId as string); + return cached === undefined ? discover(projectId) : Effect.succeed(cached); + }), + ); + + return { discover, list } satisfies BoardDiscoveryShape; +}); + +export const BoardDiscoveryLive = Layer.effect(BoardDiscovery, make); diff --git a/apps/server/src/workflow/Services/BoardDiscovery.ts b/apps/server/src/workflow/Services/BoardDiscovery.ts new file mode 100644 index 00000000000..b95fa4469d8 --- /dev/null +++ b/apps/server/src/workflow/Services/BoardDiscovery.ts @@ -0,0 +1,17 @@ +import type { BoardListEntry, ProjectId } from "@t3tools/contracts"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface BoardDiscoveryShape { + readonly discover: ( + projectId: ProjectId, + ) => Effect.Effect, WorkflowRpcError>; + readonly list: ( + projectId: ProjectId, + ) => Effect.Effect, WorkflowRpcError>; +} + +export class BoardDiscovery extends Context.Service()( + "t3/workflow/Services/BoardDiscovery", +) {} From 85cea1673a82f55bdd7e59081c43ef0b39518a2e Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:20:53 -0400 Subject: [PATCH 072/295] feat(workflow): listBoards/createBoard RPC handlers; remove registerBoardFromFile Constraint: Removing registerBoardFromFile everywhere required client/runtime/test-harness cleanup and workflow provider wiring in the same compile boundary. Rejected: Leaving temporary registerBoardFromFile shims until later tasks | the plan invariant requires grep-clean removal in Task 8. Confidence: high Scope-risk: moderate Directive: Keep board creation server-resolved; do not reintroduce client-supplied board file paths. Tested: cd apps/server && pnpm exec vp test run src/workspace/Layers/WorkspaceFileSystem.test.ts; cd apps/server && pnpm exec vp test run src/workflow/Layers/WorkflowRpcHandlers.test.ts; cd apps/server && pnpm exec vp test run src/workflow; pnpm --filter @t3tools/contracts test; pnpm exec vp run typecheck; pnpm exec vp check; rg registerBoardFromFile packages/contracts apps/server/src apps/web/src packages/client-runtime/src -n Not-tested: end-to-end browser sidebar creation flow is covered in later web tasks. --- apps/server/src/server.test.ts | 11 ++ .../Layers/WorkflowRpcHandlers.test.ts | 148 ++++++++++++++++++ .../workflow/Layers/WorkflowRpcHandlers.ts | 119 ++++++++++---- .../src/workflow/WorkflowRuntimeLive.ts | 9 ++ .../Layers/WorkspaceFileSystem.test.ts | 33 ++++ .../workspace/Layers/WorkspaceFileSystem.ts | 39 ++++- .../workspace/Services/WorkspaceFileSystem.ts | 14 ++ apps/server/src/ws.ts | 10 +- apps/web/src/environmentApi.ts | 3 +- .../service.threadSubscriptions.test.ts | 3 +- apps/web/src/localApi.test.ts | 3 +- .../src/routes/_chat.$environmentId.board.tsx | 29 +--- packages/client-runtime/src/wsRpcClient.ts | 11 +- packages/contracts/src/ipc.ts | 14 +- packages/contracts/src/rpc.ts | 15 -- packages/contracts/src/workflow.ts | 1 - 16 files changed, 371 insertions(+), 91 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 9576be90f56..4f89976b830 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -139,6 +139,8 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { BoardDiscovery } from "./workflow/Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "./workflow/Services/ProjectWorkspaceResolver.ts"; import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; @@ -553,9 +555,11 @@ const buildAppUnderTest = (options?: { }), Layer.mock(WorkflowReadModel)({ registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, getBoard: () => Effect.succeed(null), listTickets: () => Effect.succeed([]), getTicketDetail: () => Effect.succeed(null), + listBoardsForProject: () => Effect.succeed([]), }), Layer.mock(BoardRegistry)({ register: () => Effect.die("unused workflow board register"), @@ -572,6 +576,13 @@ const buildAppUnderTest = (options?: { Layer.mock(WorkflowFileLoader)({ loadAndRegister: () => Effect.die("unused workflow file load"), }), + Layer.mock(BoardDiscovery)({ + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectWorkspaceResolver)({ + resolve: () => Effect.succeed("/tmp/default-project"), + }), ); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts index 3b5dd3e4835..cb79768fe07 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -1,7 +1,9 @@ import { assert, it } from "@effect/vitest"; import { + type BoardListEntry, BoardId, LaneKey, + type ProjectId, TicketId, WORKFLOW_WS_METHODS, type WorkflowDefinition, @@ -10,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { workflowRpcHandlers } from "./WorkflowRpcHandlers.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => Effect.gen(function* () { @@ -73,6 +76,17 @@ it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => fileLoader: { loadAndRegister: () => Effect.succeed(boardId), }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + }, observeRpcEffect: (_method, effect) => effect, observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), }); @@ -97,3 +111,137 @@ it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => } }), ); + +it.effect("workflowRpcHandlers lists and creates boards without a client path", () => + Effect.gen(function* () { + const projectId = "project-rpc" as ProjectId; + const projectRoot = "/tmp/project-rpc-root"; + const rows = new Map< + string, + { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } + >(); + const definitions = new Map(); + const entries: BoardListEntry[] = []; + const writes: Array<{ + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + moveTicket: () => Effect.void, + runLane: () => Effect.void, + resolveApproval: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (boardId) => Effect.succeed(rows.get(boardId as string) ?? null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: (boardId) => Effect.succeed(definitions.get(boardId as string) ?? null), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + loadAndRegister: (input) => + Effect.sync(() => { + const definition = defaultBoardDefinition({ + name: input.relativePath.includes("-2") ? "Workflow Board" : "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + rows.set(input.boardId as string, { + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: "hash", + maxConcurrentTickets: 3, + }); + definitions.set(input.boardId as string, definition); + entries.push({ + boardId: input.boardId, + name: definition.name, + filePath: input.relativePath, + error: null, + }); + return input.boardId; + }), + }, + boardDiscovery: { + discover: () => Effect.succeed(entries), + list: () => Effect.succeed(entries), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(projectRoot), + }, + workspaceFileSystem: { + writeFile: () => Effect.die("writeFile must not be used"), + createFileExclusive: (input) => + Effect.sync(() => { + writes.push(input); + return { relativePath: input.relativePath }; + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + assert.deepEqual(yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId }), []); + + const first = yield* handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + const second = yield* handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + + assert.equal(first.boardId, `${projectId}__workflow-board`); + assert.equal(first.snapshot.projectId, projectId); + assert.equal(second.boardId, `${projectId}__workflow-board-2`); + assert.deepEqual( + writes.map((write) => ({ + projectRoot: write.projectRoot, + relativePath: write.relativePath, + })), + [ + { projectRoot, relativePath: ".t3/boards/workflow-board.json" }, + { projectRoot, relativePath: ".t3/boards/workflow-board-2.json" }, + ], + ); + assert.deepEqual( + (yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId })).map( + (entry) => entry.boardId, + ), + [`${projectId}__workflow-board`, `${projectId}__workflow-board-2`], + ); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts index 413c1b5ef01..54b956938ac 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -1,6 +1,6 @@ import type { AgentSelection, - BoardId, + BoardListEntry, BoardSnapshot, BoardTicketView, EnvironmentAuthorizationError, @@ -13,11 +13,23 @@ import type { WorkflowStepRunView, WorkflowTicketDetailView, } from "@t3tools/contracts"; -import { WORKFLOW_WS_METHODS, WorkflowRpcError } from "@t3tools/contracts"; +import { + BoardId, + WORKFLOW_WS_METHODS, + WorkflowDefinition, + WorkflowRpcError, +} from "@t3tools/contracts"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import type { WorkspaceFileSystemShape } from "../../workspace/Services/WorkspaceFileSystem.ts"; +import { slugifyBoardName, uniqueBoardSlug } from "../boardSlug.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import type { BoardDiscoveryShape } from "../Services/BoardDiscovery.ts"; import type { BoardRegistryShape } from "../Services/BoardRegistry.ts"; +import type { ProjectWorkspaceResolverShape } from "../Services/ProjectWorkspaceResolver.ts"; import type { WorkflowBoardEventsShape } from "../Services/WorkflowBoardEvents.ts"; import type { WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; import type { WorkflowFileLoaderShape } from "../Services/WorkflowFileLoader.ts"; @@ -41,10 +53,19 @@ interface WorkflowCreateTicketInput { readonly initialLane: LaneKey; } +interface WorkflowCreateBoardInput { + readonly projectId: ProjectId; + readonly name: string; + readonly agent: AgentSelection; +} + interface WorkflowRpcHandlerDeps { readonly engine: WorkflowEngineShape; readonly readModel: WorkflowReadModelShape; readonly boardRegistry: BoardRegistryShape; + readonly boardDiscovery: BoardDiscoveryShape; + readonly projectWorkspaceResolver: ProjectWorkspaceResolverShape; + readonly workspaceFileSystem: WorkspaceFileSystemShape; readonly ticketDiff: TicketDiffQueryShape; readonly ticketWorktrees: TicketWorktreeResolverShape; readonly boardEvents: WorkflowBoardEventsShape; @@ -87,6 +108,8 @@ const workflowRpcError = (message: string, cause?: unknown) => ...(cause === undefined ? {} : { cause }), }); +const encodeWorkflowDefinitionJson = Schema.encodeSync(fromJsonStringPretty(WorkflowDefinition)); + const toWorkflowRpcError = (message: string) => (cause: unknown) => workflowRpcError(message, cause); @@ -149,43 +172,73 @@ const ticketDetail = ( } satisfies WorkflowTicketDetailView; }); +const slugFromBoardEntry = (entry: BoardListEntry): string | null => { + const fileName = entry.filePath.split("/").at(-1); + return fileName?.endsWith(".json") ? fileName.slice(0, -".json".length) : null; +}; + +const createBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "boardDiscovery" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "fileLoader" + | "boardRegistry" + | "readModel" + >, + input: WorkflowCreateBoardInput, +): Effect.Effect< + { readonly boardId: BoardId; readonly snapshot: BoardSnapshot }, + WorkflowRpcError +> => + Effect.gen(function* () { + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(input.projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const existingEntries = yield* deps.boardDiscovery.discover(input.projectId); + const existingSlugs = new Set( + existingEntries.flatMap((entry) => { + const slug = slugFromBoardEntry(entry); + return slug === null ? [] : [slug]; + }), + ); + const slug = uniqueBoardSlug(slugifyBoardName(input.name), existingSlugs); + const boardId = BoardId.make(`${input.projectId}__${slug}`); + const relativePath = `.t3/boards/${slug}.json`; + const definition = defaultBoardDefinition({ name: input.name, agent: input.agent }); + + yield* deps.workspaceFileSystem + .createFileExclusive({ + projectRoot: workspaceRoot, + relativePath, + contents: `${encodeWorkflowDefinitionJson(definition)}\n`, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to create workflow board file"))); + yield* deps.fileLoader + .loadAndRegister({ + boardId, + projectId: input.projectId, + workspaceRoot, + relativePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to register created workflow board"))); + + const snapshot = yield* boardSnapshot(deps, boardId); + return { boardId, snapshot }; + }); + export const workflowRpcHandlers = (deps: WorkflowRpcHandlerDeps) => ({ - [WORKFLOW_WS_METHODS.registerBoardFromFile]: (input: { - readonly boardId: Parameters[0]["boardId"]; - readonly projectId: Parameters[0]["projectId"]; - readonly filePath: string; - readonly repoRoot: string; - }) => - deps.observeRpcEffect( - WORKFLOW_WS_METHODS.registerBoardFromFile, - deps.fileLoader - .loadAndRegister({ - boardId: input.boardId, - projectId: input.projectId, - workspaceRoot: input.repoRoot, - relativePath: input.filePath, - }) - .pipe(Effect.map((boardId) => ({ boardId }))), - { "rpc.aggregate": "workflow" }, - ), [WORKFLOW_WS_METHODS.listBoards]: (input: { readonly projectId: ProjectId }) => deps.observeRpcEffect( WORKFLOW_WS_METHODS.listBoards, - Effect.fail( - workflowRpcError(`workflow.listBoards is not implemented for ${input.projectId}`), - ), - { "rpc.aggregate": "workflow" }, - ), - [WORKFLOW_WS_METHODS.createBoard]: (input: { - readonly projectId: ProjectId; - readonly name: string; - readonly agent: AgentSelection; - }) => - deps.observeRpcEffect( - WORKFLOW_WS_METHODS.createBoard, - Effect.fail(workflowRpcError(`workflow.createBoard is not implemented for ${input.name}`)), + deps.boardDiscovery.discover(input.projectId), { "rpc.aggregate": "workflow" }, ), + [WORKFLOW_WS_METHODS.createBoard]: (input: WorkflowCreateBoardInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.createBoard, createBoard(deps, input), { + "rpc.aggregate": "workflow", + }), [WORKFLOW_WS_METHODS.getBoard]: (input: { readonly boardId: BoardId }) => deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoard, boardSnapshot(deps, input.boardId), { "rpc.aggregate": "workflow", diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts index 114bc5cba42..2f8191b85c6 100644 --- a/apps/server/src/workflow/WorkflowRuntimeLive.ts +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -1,6 +1,7 @@ import * as Layer from "effect/Layer"; import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardDiscoveryLive } from "./Layers/BoardDiscovery.ts"; import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; import { DurableApprovalResumeLive } from "./Layers/DurableApprovalResume.ts"; import { @@ -8,6 +9,7 @@ import { ProviderTurnPortLive, } from "./Layers/ProviderDispatchOutbox.ts"; import { ProviderResponsePortLive } from "./Layers/ProviderResponsePort.ts"; +import { ProjectWorkspaceResolverLive } from "./Layers/ProjectWorkspaceResolver.ts"; import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecutor.ts"; import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; import { TicketCheckpointServiceLive } from "./Layers/TicketCheckpointService.ts"; @@ -53,9 +55,16 @@ export const WorkflowRuntimeLive = WorkflowRuntimeCoreLive.pipe( Layer.provideMerge(ProviderResponsePortLive), ); +const WorkflowBoardDiscoverySupportLive = BoardDiscoveryLive.pipe( + Layer.provideMerge(ProjectWorkspaceResolverLive), + Layer.provideMerge(WorkflowFileLoaderLive), +); + export const WorkflowRpcSupportLive = Layer.mergeAll( WorkflowFileLoaderLive, TicketDiffQueryLive, + ProjectWorkspaceResolverLive, + WorkflowBoardDiscoverySupportLive, ).pipe( Layer.provideMerge(BoardRegistryLive), Layer.provideMerge(WorkflowBoardEventsLive), diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 9b93b1e863b..0121d08689a 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -136,5 +136,38 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { expect(escapedStat).toBeNull(); }), ); + + it.effect("createFileExclusive creates once and rejects an existing file", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const created = yield* workspaceFileSystem.createFileExclusive({ + projectRoot: cwd, + relativePath: ".t3/boards/workflow-board.json", + contents: "{}\n", + }); + expect(created).toEqual({ relativePath: ".t3/boards/workflow-board.json" }); + + const error = yield* workspaceFileSystem + .createFileExclusive({ + projectRoot: cwd, + relativePath: ".t3/boards/workflow-board.json", + contents: '{"overwritten":true}\n', + }) + .pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceFileSystemError"); + if (error._tag === "WorkspaceFileSystemError") { + expect(error.operation).toBe("workspaceFileSystem.createFileExclusive"); + } + + const saved = yield* fileSystem.readFileString( + path.join(cwd, ".t3/boards/workflow-board.json"), + ); + expect(saved).toBe("{}\n"); + }), + ); }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 9f53ade1bb9..b9d4d996b70 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -52,7 +52,44 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { yield* workspaceEntries.invalidate(input.cwd); return { relativePath: target.relativePath }; }); - return { writeFile } satisfies WorkspaceFileSystemShape; + + const createFileExclusive: WorkspaceFileSystemShape["createFileExclusive"] = Effect.fn( + "WorkspaceFileSystem.createFileExclusive", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.projectRoot, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.projectRoot, + relativePath: input.relativePath, + operation: "workspaceFileSystem.makeDirectory", + detail: cause.message, + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents, { flag: "wx" }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.projectRoot, + relativePath: input.relativePath, + operation: "workspaceFileSystem.createFileExclusive", + detail: cause.message, + cause, + }), + ), + ); + yield* workspaceEntries.invalidate(input.projectRoot); + return { relativePath: target.relativePath }; + }); + + return { writeFile, createFileExclusive } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 16fcdf0b57f..610d13a906b 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -40,6 +40,20 @@ export interface WorkspaceFileSystemShape { ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePathOutsideRootError >; + /** + * Create a file relative to the workspace root, failing if it already exists. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly createFileExclusive: (input: { + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }) => Effect.Effect< + ProjectWriteFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; } /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f286623c061..e8a76685051 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -104,7 +104,9 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { BoardDiscovery } from "./workflow/Services/BoardDiscovery.ts"; import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { ProjectWorkspaceResolver } from "./workflow/Services/ProjectWorkspaceResolver.ts"; import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; @@ -150,6 +152,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [WORKFLOW_WS_METHODS.listBoards, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createBoard, AuthWorkflowOperateScope], [WORKFLOW_WS_METHODS.subscribeBoard, AuthWorkflowReadScope], [WORKFLOW_WS_METHODS.getBoard, AuthWorkflowReadScope], [WORKFLOW_WS_METHODS.getTicketDetail, AuthWorkflowReadScope], @@ -158,7 +162,6 @@ const RPC_REQUIRED_SCOPE = new Map([ [WORKFLOW_WS_METHODS.moveTicket, AuthWorkflowOperateScope], [WORKFLOW_WS_METHODS.runLane, AuthWorkflowOperateScope], [WORKFLOW_WS_METHODS.resolveApproval, AuthWorkflowOperateScope], - [WORKFLOW_WS_METHODS.registerBoardFromFile, AuthWorkflowOperateScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -295,6 +298,8 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const workflowTicketDiff = yield* TicketDiffQuery; const workflowBoardEvents = yield* WorkflowBoardEvents; const workflowFileLoader = yield* WorkflowFileLoader; + const workflowBoardDiscovery = yield* BoardDiscovery; + const workflowProjectWorkspaceResolver = yield* ProjectWorkspaceResolver; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -828,6 +833,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => engine: workflowEngine, readModel: workflowReadModel, boardRegistry: workflowBoardRegistry, + boardDiscovery: workflowBoardDiscovery, + projectWorkspaceResolver: workflowProjectWorkspaceResolver, + workspaceFileSystem, ticketDiff: workflowTicketDiff, ticketWorktrees, boardEvents: workflowBoardEvents, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index f079a2fec5d..da6775751b3 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -59,7 +59,8 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { rpcClient.orchestration.subscribeThread(input, callback, options), }, workflow: { - registerBoardFromFile: rpcClient.workflow.registerBoardFromFile, + listBoards: rpcClient.workflow.listBoards, + createBoard: rpcClient.workflow.createBoard, getBoard: rpcClient.workflow.getBoard, subscribeBoard: (input, callback, options) => rpcClient.workflow.subscribeBoard(input, callback, options), diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index e41e40bc087..d2ae1ef5163 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -139,7 +139,8 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { getDiffPreview: vi.fn(), }, workflow: { - registerBoardFromFile: vi.fn(), + listBoards: vi.fn(), + createBoard: vi.fn(), getBoard: vi.fn(), subscribeBoard: vi.fn(() => () => undefined), createTicket: vi.fn(), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 52b041e7d30..94b7ec9f7d6 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -91,7 +91,8 @@ const rpcClientMock = { getDiffPreview: vi.fn(), }, workflow: { - registerBoardFromFile: vi.fn(), + listBoards: vi.fn(), + createBoard: vi.fn(), getBoard: vi.fn(), subscribeBoard: vi.fn(() => () => undefined), createTicket: vi.fn(), diff --git a/apps/web/src/routes/_chat.$environmentId.board.tsx b/apps/web/src/routes/_chat.$environmentId.board.tsx index 83181ca3edf..836e0ad65ec 100644 --- a/apps/web/src/routes/_chat.$environmentId.board.tsx +++ b/apps/web/src/routes/_chat.$environmentId.board.tsx @@ -15,7 +15,7 @@ import { TicketDrawer } from "../components/board/TicketDrawer"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; import { readEnvironmentApi } from "../environmentApi"; -import { selectProjectsForEnvironment, useStore } from "../store"; +import { useStore } from "../store"; import { emptyBoardState } from "../workflow/boardState"; import { createTicket, moveTicket, resolveApproval, subscribeBoard } from "../workflow/boardRpc"; @@ -40,8 +40,6 @@ function WorkflowBoardRouteView() { const state = useStore((store) => boardId ? (store.boardStateById[boardId] ?? emptyBoardState) : emptyBoardState, ); - const projects = useStore((store) => selectProjectsForEnvironment(store, environmentId)); - const registrationProject = projects[0]; useEffect(() => { if (!boardId) { @@ -146,28 +144,6 @@ function WorkflowBoardRouteView() { }, [handleMove, reloadTicketDetail, selectedTicketId], ); - const handleRegisterBoard = useCallback(() => { - if (!boardId || !registrationProject) { - return; - } - - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } - - void api.workflow - .registerBoardFromFile({ - boardId, - projectId: registrationProject.id, - filePath: ".t3/boards/delivery.json", - repoRoot: registrationProject.cwd, - }) - .then(() => api.workflow.getBoard({ boardId })) - .then((snapshot) => { - useStore.getState().applyBoardStreamItem(boardId, { kind: "snapshot", snapshot }); - }); - }, [boardId, environmentId, registrationProject]); const handleCreateTicket = useCallback( (input: { readonly title: string; readonly initialLane: string }) => { if (!boardId) { @@ -203,9 +179,8 @@ function WorkflowBoardRouteView() { undefined} /> {boardId ? ( diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 220931ea62d..ee73a928b60 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -170,9 +170,8 @@ export interface WsRpcClient { readonly subscribeThread: RpcInputStreamMethod; }; readonly workflow: { - readonly registerBoardFromFile: RpcUnaryMethod< - typeof WORKFLOW_WS_METHODS.registerBoardFromFile - >; + readonly listBoards: RpcUnaryMethod; + readonly createBoard: RpcUnaryMethod; readonly getBoard: RpcUnaryMethod; readonly subscribeBoard: RpcInputStreamMethod; readonly createTicket: RpcUnaryMethod; @@ -387,8 +386,10 @@ export function createWsRpcClient( ), }, workflow: { - registerBoardFromFile: (input) => - transport.request((client) => client[WORKFLOW_WS_METHODS.registerBoardFromFile](input)), + listBoards: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.listBoards](input)), + createBoard: (input) => + transport.request((client) => client[WORKFLOW_WS_METHODS.createBoard](input)), getBoard: (input) => transport.request((client) => client[WORKFLOW_WS_METHODS.getBoard](input)), subscribeBoard: (input, listener, options) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e66932ac84c..c7231a21f62 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -81,7 +81,9 @@ import type { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import type { + AgentSelection, BoardId, + BoardListEntry, BoardSnapshot, BoardStreamItem, LaneKey, @@ -622,12 +624,14 @@ export interface EnvironmentApi { ) => () => void; }; workflow: { - registerBoardFromFile: (input: { - readonly boardId: BoardId; + listBoards: (input: { + readonly projectId: ProjectId; + }) => Promise>; + createBoard: (input: { readonly projectId: ProjectId; - readonly filePath: string; - readonly repoRoot: string; - }) => Promise<{ readonly boardId: BoardId }>; + readonly name: string; + readonly agent: AgentSelection; + }) => Promise<{ readonly boardId: BoardId; readonly snapshot: BoardSnapshot }>; getBoard: (input: { readonly boardId: BoardId }) => Promise; subscribeBoard: ( input: { readonly boardId: BoardId }, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 249a11a6da0..061645953ec 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -525,20 +525,6 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( }, ); -export const WsWorkflowRegisterBoardFromFileRpc = Rpc.make( - WORKFLOW_WS_METHODS.registerBoardFromFile, - { - payload: Schema.Struct({ - boardId: BoardId, - projectId: ProjectId, - filePath: Schema.String, - repoRoot: Schema.String, - }), - success: Schema.Struct({ boardId: BoardId }), - error: Schema.Union([WorkflowRpcError, EnvironmentAuthorizationError]), - }, -); - export const WsWorkflowListBoardsRpc = Rpc.make(WORKFLOW_WS_METHODS.listBoards, { payload: Schema.Struct({ projectId: ProjectId }), success: Schema.Array(BoardListEntry), @@ -698,7 +684,6 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, - WsWorkflowRegisterBoardFromFileRpc, WsWorkflowListBoardsRpc, WsWorkflowCreateBoardRpc, WsWorkflowGetBoardRpc, diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts index 083ca1c5a7a..b801ecd3bc0 100644 --- a/packages/contracts/src/workflow.ts +++ b/packages/contracts/src/workflow.ts @@ -9,7 +9,6 @@ import { } from "./baseSchemas.ts"; export const WORKFLOW_WS_METHODS = { - registerBoardFromFile: "workflow.registerBoardFromFile", listBoards: "workflow.listBoards", createBoard: "workflow.createBoard", getBoard: "workflow.getBoard", From 746653dbc8aee9aa34614565f868da7c120ab5a2 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:21:46 -0400 Subject: [PATCH 073/295] feat(workflow): provide BoardDiscovery + workspace resolver in runtime Constraint: Task 8's grep-clean handler removal made the runtime wiring necessary before that compile boundary, so this commit records the verified Task 9 boundary. Rejected: Reintroducing a temporary registerBoardFromFile shim to defer runtime wiring | it violates the Task 8 removal invariant. Confidence: high Scope-risk: narrow Directive: Keep discovery on-demand through listBoards; do not add a watcher or boardsChanged push for v1. Tested: pnpm exec vp run typecheck; cd apps/server && pnpm exec vp test run src/workflow; pnpm exec vp check Not-tested: no additional runtime code changed after Task 8. From d54752a73ab57cacb56b6a24f9948708e886bd7b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:23:35 -0400 Subject: [PATCH 074/295] feat(web): client wiring for listBoards/createBoard Constraint: registerBoardFromFile client/runtime entries were removed at the Task 8 compile boundary; this task completes the web helper surface. Rejected: Passing a board file path through the helper | createBoard is server-resolved by projectId/name/agent only. Confidence: high Scope-risk: narrow Directive: Keep board creation callers using EnvironmentApi.workflow.createBoard with no client path field. Tested: cd apps/web && pnpm exec vp test run src/workflow/boardRpc.test.ts; cd apps/web && pnpm exec vp test run src/workflow; pnpm exec vp run typecheck; pnpm exec vp check Not-tested: sidebar create button flow is covered in later tasks. --- apps/web/src/workflow/boardRpc.test.ts | 50 ++++++++++++++++++++++++++ apps/web/src/workflow/boardRpc.ts | 17 ++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/workflow/boardRpc.test.ts diff --git a/apps/web/src/workflow/boardRpc.test.ts b/apps/web/src/workflow/boardRpc.test.ts new file mode 100644 index 00000000000..ae464f094e4 --- /dev/null +++ b/apps/web/src/workflow/boardRpc.test.ts @@ -0,0 +1,50 @@ +import { + BoardId, + type AgentSelection, + type BoardListEntry, + type BoardSnapshot, + type EnvironmentApi, + type ProjectId, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createBoard, listBoards } from "./boardRpc"; + +describe("boardRpc", () => { + it("delegates listBoards and createBoard through the workflow EnvironmentApi", async () => { + const projectId = "project-web" as ProjectId; + const boardId = BoardId.make("project-web__delivery"); + const agent = { instance: "codex_main", model: "gpt-5.5" } satisfies AgentSelection; + const entries = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ] satisfies BoardListEntry[]; + const snapshot = { + projectId, + board: { boardId, name: "Delivery", lanes: [] }, + tickets: [], + } satisfies BoardSnapshot; + const api = { + workflow: { + listBoards: vi.fn(async () => entries), + createBoard: vi.fn(async () => ({ + boardId, + snapshot, + })), + }, + } as unknown as EnvironmentApi; + + await expect(listBoards(api, projectId)).resolves.toBe(entries); + await expect(createBoard(api, { projectId, name: "Delivery", agent })).resolves.toEqual({ + boardId, + snapshot, + }); + + expect(api.workflow.listBoards).toHaveBeenCalledWith({ projectId }); + expect(api.workflow.createBoard).toHaveBeenCalledWith({ projectId, name: "Delivery", agent }); + }); +}); diff --git a/apps/web/src/workflow/boardRpc.ts b/apps/web/src/workflow/boardRpc.ts index c089af19e9e..192c97f31b7 100644 --- a/apps/web/src/workflow/boardRpc.ts +++ b/apps/web/src/workflow/boardRpc.ts @@ -1,4 +1,11 @@ -import type { BoardId, EnvironmentApi, LaneKey, StepRunId, TicketId } from "@t3tools/contracts"; +import type { + BoardId, + EnvironmentApi, + LaneKey, + ProjectId, + StepRunId, + TicketId, +} from "@t3tools/contracts"; import { useStore } from "../store"; @@ -22,6 +29,14 @@ export const createTicket = ( input: Parameters[0], ) => api.workflow.createTicket(input); +export const listBoards = (api: EnvironmentApi, projectId: ProjectId) => + api.workflow.listBoards({ projectId }); + +export const createBoard = ( + api: EnvironmentApi, + input: Parameters[0], +) => api.workflow.createBoard(input); + export const moveTicket = (api: EnvironmentApi, ticketId: TicketId, toLane: LaneKey) => api.workflow.moveTicket({ ticketId, toLane }); From 8c92fa73c48b36a21f0d00ca2ef66d139d5995ea Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:27:13 -0400 Subject: [PATCH 075/295] feat(web): resolveRecentAgent helper Constraint: Board creation must default to the user's most recent available agent without reading full thread details. Rejected: Reading sidebar summaries for modelSelection | summaries intentionally omit modelSelection, so the resolver uses thread shells. Confidence: high Scope-risk: narrow Directive: Keep availability gated by enabled, installed, and isAvailable provider entries. Tested: cd apps/web && pnpm exec vp test run src/workflow/resolveRecentAgent.test.ts; cd apps/web && pnpm exec vp test run src/workflow; pnpm exec vp run typecheck; pnpm exec vp check Not-tested: sidebar create-button integration is covered in later tasks. --- .../src/workflow/resolveRecentAgent.test.ts | 235 ++++++++++++++++++ apps/web/src/workflow/resolveRecentAgent.ts | 89 +++++++ 2 files changed, 324 insertions(+) create mode 100644 apps/web/src/workflow/resolveRecentAgent.test.ts create mode 100644 apps/web/src/workflow/resolveRecentAgent.ts diff --git a/apps/web/src/workflow/resolveRecentAgent.test.ts b/apps/web/src/workflow/resolveRecentAgent.test.ts new file mode 100644 index 00000000000..07cd17f2af6 --- /dev/null +++ b/apps/web/src/workflow/resolveRecentAgent.test.ts @@ -0,0 +1,235 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry"; +import { setServerConfigSnapshot } from "../rpc/serverState"; +import { type EnvironmentState, useStore } from "../store"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ThreadShell } from "../types"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { pickRecentAgent, resolveRecentAgent } from "./resolveRecentAgent"; + +const environmentId = EnvironmentId.make("environment-recent-agent"); +const projectId = ProjectId.make("project-recent-agent"); + +const makeProvider = (input: { + readonly instanceId: string; + readonly driver?: string; + readonly enabled?: boolean; + readonly installed?: boolean; + readonly availability?: "available" | "unavailable"; + readonly models?: ReadonlyArray<{ readonly slug: string; readonly isCustom?: boolean }>; +}): ServerProvider => ({ + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver ?? "codex"), + enabled: input.enabled ?? true, + installed: input.installed ?? true, + version: "0.0.0-test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-07T00:00:00.000Z", + availability: input.availability, + models: (input.models ?? []).map((model) => ({ + slug: model.slug, + name: model.slug, + isCustom: model.isCustom ?? false, + capabilities: {}, + })), + slashCommands: [], + skills: [], +}); + +const makeServerConfig = (providers: ReadonlyArray): ServerConfig => ({ + environment: { + environmentId, + label: "Recent agent test", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/tmp/recent-agent", + keybindingsConfigPath: "/tmp/recent-agent/keybindings.json", + keybindings: [], + issues: [], + providers, + availableEditors: [], + observability: { + logsDirectoryPath: "/tmp/recent-agent/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: DEFAULT_SERVER_SETTINGS, +}); + +const makeShell = (input: { + readonly id: string; + readonly instanceId: string; + readonly model: string; + readonly createdAt: string; + readonly updatedAt?: string; +}): ThreadShell => ({ + id: ThreadId.make(input.id), + environmentId, + codexThreadId: null, + projectId, + title: input.id, + modelSelection: { + instanceId: ProviderInstanceId.make(input.instanceId), + model: input.model, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + error: null, + createdAt: input.createdAt, + archivedAt: null, + updatedAt: input.updatedAt, + branch: null, + worktreePath: null, +}); + +const makeEnvironmentState = (shells: ReadonlyArray): EnvironmentState => ({ + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + environmentId, + name: "Recent agent project", + cwd: "/tmp/recent-agent", + defaultModelSelection: null, + createdAt: "2026-06-07T00:00:00.000Z", + updatedAt: "2026-06-07T00:00:00.000Z", + scripts: [], + }, + }, + threadIds: shells.map((shell) => shell.id), + threadIdsByProjectId: { [projectId]: shells.map((shell) => shell.id) }, + threadShellById: Object.fromEntries(shells.map((shell) => [shell.id, shell])), + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, +}); + +beforeEach(() => { + resetAppAtomRegistryForTests(); + useStore.setState({ + activeEnvironmentId: null, + environmentStateById: {}, + boardStateById: {}, + }); + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); +}); + +describe("pickRecentAgent", () => { + const avail = (id: string) => id === "codex" || id === "claude_main"; + + it("prefers sticky when available", () => { + expect( + pickRecentAgent({ + sticky: { instance: "codex", model: "gpt-5.4" }, + recentThread: { instance: "claude_main", model: "sonnet" }, + defaultChoice: { instance: "claude_main", model: "sonnet" }, + isAvailable: avail, + }), + ).toEqual({ instance: "codex", model: "gpt-5.4" }); + }); + + it("falls through unavailable sticky to recent thread", () => { + expect( + pickRecentAgent({ + sticky: { instance: "ghost", model: "x" }, + recentThread: { instance: "claude_main", model: "sonnet" }, + defaultChoice: null, + isAvailable: avail, + }), + ).toEqual({ instance: "claude_main", model: "sonnet" }); + }); + + it("returns null when nothing is available", () => { + expect( + pickRecentAgent({ + sticky: null, + recentThread: null, + defaultChoice: null, + isAvailable: avail, + }), + ).toBeNull(); + }); +}); + +describe("resolveRecentAgent", () => { + it("uses thread shells for the most recent available agent before defaulting", () => { + setServerConfigSnapshot( + makeServerConfig([ + makeProvider({ + instanceId: "ghost", + enabled: true, + installed: true, + availability: "unavailable", + models: [{ slug: "ghost-model" }], + }), + makeProvider({ + instanceId: "claude_main", + driver: "claudeAgent", + models: [{ slug: "sonnet" }, { slug: "custom-claude", isCustom: true }], + }), + ]), + ); + useComposerDraftStore.setState({ + stickyActiveProvider: ProviderInstanceId.make("ghost"), + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("ghost")]: { + instanceId: ProviderInstanceId.make("ghost"), + model: "ghost-model", + }, + }, + }); + const olderShell = makeShell({ + id: "thread-old", + instanceId: "claude_main", + model: "old-sonnet", + createdAt: "2026-06-06T00:00:00.000Z", + }); + const newerShell = makeShell({ + id: "thread-new", + instanceId: "claude_main", + model: "sonnet", + createdAt: "2026-06-06T00:00:00.000Z", + updatedAt: "2026-06-07T00:00:00.000Z", + }); + useStore.setState({ + activeEnvironmentId: environmentId, + environmentStateById: { + [environmentId]: makeEnvironmentState([olderShell, newerShell]), + }, + }); + + expect(resolveRecentAgent()).toEqual({ instance: "claude_main", model: "sonnet" }); + }); +}); diff --git a/apps/web/src/workflow/resolveRecentAgent.ts b/apps/web/src/workflow/resolveRecentAgent.ts new file mode 100644 index 00000000000..b7d45a1ba62 --- /dev/null +++ b/apps/web/src/workflow/resolveRecentAgent.ts @@ -0,0 +1,89 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + type AgentSelection, + type ModelSelection, + type ProviderInstanceId, +} from "@t3tools/contracts"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { deriveProviderInstanceEntries } from "../providerInstances"; +import { getServerConfig } from "../rpc/serverState"; +import { selectThreadShellsAcrossEnvironments, useStore } from "../store"; + +type AgentChoice = AgentSelection | null; + +export interface RecentAgentSources { + readonly sticky: AgentChoice; + readonly recentThread: AgentChoice; + readonly defaultChoice: AgentChoice; + readonly isAvailable: (instance: string) => boolean; +} + +export function pickRecentAgent(sources: RecentAgentSources): AgentSelection | null { + for (const candidate of [sources.sticky, sources.recentThread, sources.defaultChoice]) { + if (candidate && sources.isAvailable(candidate.instance)) { + return candidate; + } + } + return null; +} + +const fromModelSelection = (selection: ModelSelection | null | undefined): AgentChoice => + selection + ? { + instance: selection.instanceId, + model: selection.model, + } + : null; + +function resolveStickyAgent(): AgentChoice { + const composerState = useComposerDraftStore.getState(); + const activeProvider = composerState.stickyActiveProvider; + return activeProvider + ? fromModelSelection(composerState.stickyModelSelectionByProvider[activeProvider]) + : null; +} + +function resolveRecentThreadAgent(): AgentChoice { + const [latestShell] = selectThreadShellsAcrossEnvironments(useStore.getState()).sort( + (left, right) => + (right.updatedAt ?? right.createdAt).localeCompare(left.updatedAt ?? left.createdAt), + ); + return fromModelSelection(latestShell?.modelSelection); +} + +function resolveDefaultAgent(input: { + readonly entries: ReturnType; +}): AgentChoice { + const entry = input.entries[0]; + if (!entry) { + return null; + } + const model = + entry.models.find((candidate) => !candidate.isCustom)?.slug ?? + entry.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]; + if (!model) { + return null; + } + return { + instance: entry.instanceId, + model, + }; +} + +export function resolveRecentAgent(): AgentSelection | null { + const availableEntries = deriveProviderInstanceEntries(getServerConfig()?.providers ?? []).filter( + (entry) => entry.enabled && entry.installed && entry.isAvailable, + ); + const availableInstances = new Set( + availableEntries.map((entry) => entry.instanceId), + ); + + return pickRecentAgent({ + sticky: resolveStickyAgent(), + recentThread: resolveRecentThreadAgent(), + defaultChoice: resolveDefaultAgent({ entries: availableEntries }), + isAvailable: (instance) => availableInstances.has(instance as ProviderInstanceId), + }); +} From ec2c588f2dff8d2bf918dcebf0114e83cc00b6cd Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:29:14 -0400 Subject: [PATCH 076/295] feat(web): per-project board-list store slice Constraint: Board discovery is on-demand in v1, so the store needs explicit per-project replacement rather than live push merging. Rejected: Appending board list entries incrementally | listBoards is a snapshot and deleted files must disappear on the next fetch. Confidence: high Scope-risk: narrow Directive: Treat setProjectBoards/applyBoardList as replacement semantics for each project. Tested: cd apps/web && pnpm exec vp test run src/workflow/boardListState.test.ts; cd apps/web && pnpm exec vp test run src/workflow; pnpm exec vp run typecheck; pnpm exec vp check Not-tested: sidebar fetch/refetch wiring is covered in Task 13. --- apps/web/src/environmentGrouping.test.ts | 1 + apps/web/src/store.test.ts | 3 ++ apps/web/src/store.ts | 29 ++++++++++++++ apps/web/src/workflow/boardListState.test.ts | 38 +++++++++++++++++++ .../src/workflow/resolveRecentAgent.test.ts | 1 + 5 files changed, 72 insertions(+) create mode 100644 apps/web/src/workflow/boardListState.test.ts diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index a842910276a..5b3844c8c3f 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -218,6 +218,7 @@ function makeFixtureState(): AppState { [remoteEnvId]: remoteEnvState, }, boardStateById: {}, + boardsByProjectId: {}, }; } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c1494b9ebd0..7d73124076d 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -57,6 +57,7 @@ function withActiveEnvironmentState( activeEnvironmentId, environmentStateById, boardStateById: {}, + boardsByProjectId: {}, }; } @@ -264,6 +265,7 @@ describe("environment state removal", () => { [localEnvironmentId]: keptState, }, boardStateById: {}, + boardsByProjectId: {}, }; const next = removeEnvironmentState(state, remoteEnvironmentId); @@ -424,6 +426,7 @@ describe("setThreadBranch", () => { [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), }, boardStateById: {}, + boardsByProjectId: {}, }; const next = setThreadBranch( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 240b17ef560..eced5f5773f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,5 +1,6 @@ import type { BoardId, + BoardListEntry, BoardStreamItem, EnvironmentId, MessageId, @@ -107,6 +108,7 @@ export interface AppState { activeEnvironmentId: EnvironmentId | null; environmentStateById: Record; boardStateById: Record; + boardsByProjectId: Record>; } const initialEnvironmentState: EnvironmentState = { @@ -133,6 +135,7 @@ const initialState: AppState = { activeEnvironmentId: null, environmentStateById: {}, boardStateById: {}, + boardsByProjectId: {}, }; const MAX_THREAD_MESSAGES = 2_000; @@ -1868,6 +1871,15 @@ export function selectThreadIdsByProjectRef( : EMPTY_THREAD_IDS; } +const EMPTY_BOARD_LIST: ReadonlyArray = []; + +export function selectBoardsForProject( + state: AppState, + projectId: ProjectId, +): ReadonlyArray { + return state.boardsByProjectId[projectId] ?? EMPTY_BOARD_LIST; +} + export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { if (state.activeEnvironmentId === null) { return state; @@ -1979,6 +1991,20 @@ export function applyWorkflowBoardStreamItem( }; } +export function applyBoardList( + state: AppState, + projectId: ProjectId, + entries: ReadonlyArray, +): AppState { + return { + ...state, + boardsByProjectId: { + ...state.boardsByProjectId, + [projectId]: entries, + }, + }; +} + interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; removeEnvironmentState: (environmentId: EnvironmentId) => void; @@ -2000,6 +2026,7 @@ interface AppStore extends AppState { worktreePath: string | null, ) => void; applyBoardStreamItem: (boardId: BoardId, item: BoardStreamItem) => void; + setProjectBoards: (projectId: ProjectId, entries: ReadonlyArray) => void; } export const useStore = create((set) => ({ @@ -2023,4 +2050,6 @@ export const useStore = create((set) => ({ set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), applyBoardStreamItem: (boardId, item) => set((state) => applyWorkflowBoardStreamItem(state, boardId, item)), + setProjectBoards: (projectId, entries) => + set((state) => applyBoardList(state, projectId, entries)), })); diff --git a/apps/web/src/workflow/boardListState.test.ts b/apps/web/src/workflow/boardListState.test.ts new file mode 100644 index 00000000000..382151be44e --- /dev/null +++ b/apps/web/src/workflow/boardListState.test.ts @@ -0,0 +1,38 @@ +import { BoardId, ProjectId, type BoardListEntry } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { applyBoardList, selectBoardsForProject, type AppState } from "../store"; + +const projectId = ProjectId.make("project-board-list"); +const otherProjectId = ProjectId.make("project-other"); + +const entry = (slug: string, name: string): BoardListEntry => ({ + boardId: BoardId.make(`${projectId}__${slug}`), + name, + filePath: `.t3/boards/${slug}.json`, + error: null, +}); + +const makeState = (): AppState => ({ + activeEnvironmentId: null, + environmentStateById: {}, + boardStateById: {}, + boardsByProjectId: {}, +}); + +describe("board list store slice", () => { + it("stores, selects, and replaces project board entries", () => { + const first = [entry("delivery", "Delivery")]; + const second = [entry("triage", "Triage")]; + const empty = makeState(); + + expect(selectBoardsForProject(empty, projectId)).toEqual([]); + + const withBoards = applyBoardList(empty, projectId, first); + expect(selectBoardsForProject(withBoards, projectId)).toEqual(first); + expect(selectBoardsForProject(withBoards, otherProjectId)).toEqual([]); + + const replaced = applyBoardList(withBoards, projectId, second); + expect(selectBoardsForProject(replaced, projectId)).toEqual(second); + }); +}); diff --git a/apps/web/src/workflow/resolveRecentAgent.test.ts b/apps/web/src/workflow/resolveRecentAgent.test.ts index 07cd17f2af6..97d2cf970c0 100644 --- a/apps/web/src/workflow/resolveRecentAgent.test.ts +++ b/apps/web/src/workflow/resolveRecentAgent.test.ts @@ -139,6 +139,7 @@ beforeEach(() => { activeEnvironmentId: null, environmentStateById: {}, boardStateById: {}, + boardsByProjectId: {}, }); useComposerDraftStore.setState({ stickyModelSelectionByProvider: {}, From 4651c0eab5b3b9b2380cc7a7f743e98fe2fc6058 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:42:24 -0400 Subject: [PATCH 077/295] feat(web): sidebar board rows + Add board affordance Add the board creation action in the same project header flow as new threads, so discovered boards are visible and navigable from the sidebar without a separate registration step. Constraint: Task 13 requires a one-click project Add board affordance, sidebar board rows, and a manual running-app verification path. Rejected: Client-supplied board paths | createBoard is server-resolved by projectId and agent only. Confidence: high Scope-risk: moderate Directive: Keep board creation coupled to listBoards refetches until a future watcher/push design is explicitly introduced. Tested: cd apps/web && pnpm exec vp test run src/components/Sidebar.logic.test.ts; cd apps/web && pnpm exec vp test run src/workflow; pnpm exec vp run typecheck; pnpm exec vp check; Playwright against http://localhost:5734 created a Workflow board row and navigated to /board?boardId=... Not-tested: Multi-project context-menu branch was not clicked manually; implementation mirrors the existing new-thread member picker. --- apps/web/src/components/Sidebar.logic.test.ts | 9 + apps/web/src/components/Sidebar.logic.ts | 14 + apps/web/src/components/Sidebar.tsx | 305 +++++++++++++++++- 3 files changed, 316 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f8491..873960fac02 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,6 +5,7 @@ import { createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, + nextDefaultBoardName, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, @@ -274,6 +275,14 @@ describe("resolveSidebarNewThreadSeedContext", () => { }); }); +describe("nextDefaultBoardName", () => { + it("chooses the first unused Workflow board name", () => { + expect(nextDefaultBoardName([])).toBe("Workflow board"); + expect(nextDefaultBoardName(["Workflow board"])).toBe("Workflow board 2"); + expect(nextDefaultBoardName(["Workflow board", "Workflow board 2"])).toBe("Workflow board 3"); + }); +}); + describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b9dd27dfb03..1fe8e3a6595 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -213,6 +213,20 @@ export function resolveSidebarNewThreadSeedContext(input: { }; } +export function nextDefaultBoardName(existingNames: readonly string[]): string { + const existing = new Set(existingNames); + const baseName = "Workflow board"; + if (!existing.has(baseName)) { + return baseName; + } + for (let index = 2; ; index += 1) { + const candidate = `${baseName} ${index}`; + if (!existing.has(candidate)) { + return candidate; + } + } +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3f163b017d..865e133bd82 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { FolderPlusIcon, SearchIcon, SettingsIcon, + SquareKanbanIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -40,6 +41,8 @@ import { type ContextMenuItem, type DesktopUpdateState, ProjectId, + type BoardListEntry, + type EnvironmentId, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, @@ -67,6 +70,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, + selectBoardsForProject, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, @@ -168,6 +172,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + nextDefaultBoardName, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, @@ -178,6 +183,8 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; +import { createBoard, listBoards } from "../workflow/boardRpc"; +import { resolveRecentAgent } from "../workflow/resolveRecentAgent"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { @@ -719,13 +726,82 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); }); +interface SidebarBoardRowProps { + entry: BoardListEntry; + environmentId: EnvironmentId; + isActive: boolean; +} + +const SidebarBoardRow = memo(function SidebarBoardRow(props: SidebarBoardRowProps) { + const { entry, environmentId, isActive } = props; + const linkRender = useMemo( + () => ( + + ), + [entry.boardId, environmentId], + ); + + return ( + + + + + + {entry.name}} + /> + + {entry.name} + + + + {entry.error ? ( + + + + + } + /> + + {entry.error} + + + ) : null} + + + ); +}); + +interface SidebarProjectBoardRow { + readonly entry: BoardListEntry; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; + renderedBoards: readonly SidebarProjectBoardRow[]; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadSummary[]; + activeRouteBoardId: string | null; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -772,10 +848,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { projectKey, projectExpanded, + renderedBoards, hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + activeRouteBoardId, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -822,6 +900,15 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
) : null} + {shouldShowThreadPanel && + renderedBoards.map((board) => ( + + ))} {shouldShowThreadPanel && renderedThreads.map((thread) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); @@ -896,6 +983,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; + activeRouteBoardId: string | null; newThreadShortcutLabel: string | null; handleNewThread: ReturnType["handleNewThread"]; archiveThread: ReturnType["archiveThread"]; @@ -916,6 +1004,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, isThreadListExpanded, activeRouteThreadKey, + activeRouteBoardId, newThreadShortcutLabel, handleNewThread, archiveThread, @@ -1028,6 +1117,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); + const projectBoardLists = useStore( + useShallow( + useMemo( + () => (state: import("../store").AppState) => + project.memberProjects.map((member) => selectBoardsForProject(state, member.id)), + [project.memberProjects], + ), + ), + ); + const projectBoards = useMemo( + () => + project.memberProjects.flatMap((member, index) => + (projectBoardLists[index] ?? []).map((entry) => ({ + entry, + environmentId: member.environmentId, + projectId: member.id, + })), + ), + [project.memberProjects, projectBoardLists], + ); const sidebarThreadByKey = useMemo( () => new Map( @@ -1047,6 +1156,51 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); + const fetchBoardsForProjectMember = useCallback(async (member: SidebarProjectGroupMember) => { + const api = readEnvironmentApi(member.environmentId); + if (!api) { + return []; + } + const entries = await listBoards(api, member.id); + useStore.getState().setProjectBoards(member.id, entries); + return entries; + }, []); + + useEffect(() => { + if (!projectExpanded) { + return; + } + + let cancelled = false; + for (const member of project.memberProjects) { + const api = readEnvironmentApi(member.environmentId); + if (!api) { + continue; + } + void listBoards(api, member.id) + .then((entries) => { + if (!cancelled) { + useStore.getState().setProjectBoards(member.id, entries); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to load boards for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } + + return () => { + cancelled = true; + }; + }, [project.memberProjects, projectExpanded]); const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -1194,12 +1348,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ), renderedThreads, - showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, + showEmptyThreadState: + projectExpanded && visibleProjectThreads.length === 0 && projectBoards.length === 0, shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, }; }, [ isThreadListExpanded, pinnedCollapsedThread, + projectBoards.length, projectExpanded, projectThreads, sidebarThreadPreviewCount, @@ -1703,6 +1859,58 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], ); + const createBoardForProjectMember = useCallback( + async (member: SidebarProjectGroupMember) => { + const agent = resolveRecentAgent(); + if (!agent) { + toastManager.add({ + type: "error", + title: "No available agent", + description: "Enable an installed agent before creating a workflow board.", + }); + return; + } + + const api = readEnvironmentApi(member.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return; + } + + const existingBoards = selectBoardsForProject(useStore.getState(), member.id); + const name = nextDefaultBoardName(existingBoards.map((entry) => entry.name)); + + try { + const created = await createBoard(api, { + projectId: member.id, + name, + agent, + }); + await fetchBoardsForProjectMember(member); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/board", + params: { environmentId: member.environmentId }, + search: { boardId: created.boardId }, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to create board for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [fetchBoardsForProjectMember, isMobile, router, setOpenMobile], + ); + const handleCreateThreadClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1743,6 +1951,46 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); + const handleAddBoardClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (project.memberProjects.length === 1) { + void createBoardForProjectMember(project.memberProjects[0]!); + return; + } + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + const clicked = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ); + if (!clicked) { + return; + } + const targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clicked, + ); + if (!targetMember) { + return; + } + await createBoardForProjectMember(targetMember); + })(); + }, + [createBoardForProjectMember, project.groupedProjectCount, project.memberProjects], + ); + const attemptArchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { try { @@ -1977,6 +2225,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); + const canCreateBoard = resolveRecentAgent() !== null; + return ( <>
@@ -2051,10 +2301,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec )} - - +
+ + + + + } + /> + + {canCreateBoard ? "Add board" : "No available agent"} + + + + -
- } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - -
+ } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
{ + const search = loc.search as { readonly boardId?: unknown }; + return typeof search.boardId === "string" ? search.boardId : null; + }, + }); return ( @@ -2727,6 +3006,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardId={activeRouteBoardId} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -2759,6 +3039,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardId={activeRouteBoardId} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} From fbacc85b024ef9bcf13a02e58e0999b559807fbd Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 16:46:17 -0400 Subject: [PATCH 078/295] feat(web): board route uses real boardId; drop manual registration Remove the manual board registration affordance now that board creation and discovery provide real board ids from the sidebar path. Constraint: Task 14 requires the board route to open sidebar-provided boardId values without a Register step and to render an explicit missing-board state. Rejected: Keeping a disabled Register button | the v1 creation path no longer has a manual registration workflow. Confidence: high Scope-risk: narrow Directive: Keep board runtime actions routed through subscribeBoard/createTicket/moveTicket/resolveApproval/runLane; this commit only changes route presentation and board lookup state. Tested: cd apps/web && pnpm exec vp test run src/components/board/BoardHeaderControls.test.tsx 'src/routes/-boardRouteState.test.ts'; cd apps/web && pnpm exec vp test run src/workflow; pnpm exec vp run typecheck; pnpm exec vp check; Playwright opened sidebar board with no Register button and showed Board not found for a deleted/missing board id. Not-tested: Browser test did not create a ticket from the cleaned route; ticket flows were left unchanged and covered by existing workflow tests. --- .../board/BoardHeaderControls.test.tsx | 12 +-- .../components/board/BoardHeaderControls.tsx | 23 +----- apps/web/src/routes/-boardRouteState.test.ts | 22 ++++++ .../src/routes/_chat.$environmentId.board.tsx | 75 +++++++++++++++++-- 4 files changed, 95 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/routes/-boardRouteState.test.ts diff --git a/apps/web/src/components/board/BoardHeaderControls.test.tsx b/apps/web/src/components/board/BoardHeaderControls.test.tsx index e2ae91c9833..cba86d3c466 100644 --- a/apps/web/src/components/board/BoardHeaderControls.test.tsx +++ b/apps/web/src/components/board/BoardHeaderControls.test.tsx @@ -14,18 +14,12 @@ describe("BoardHeaderControls", () => { expect(getDefaultInitialLane([])).toBeNull(); }); - it("renders register and new-ticket controls", () => { + it("renders only new-ticket controls", () => { const markup = renderToStaticMarkup( - {}} - onRegisterBoard={() => {}} - />, + {}} />, ); - expect(markup).toContain("Register board"); + expect(markup).not.toContain("Register board"); expect(markup).toContain("New ticket"); expect(markup).toContain("Backlog"); expect(markup).toContain("Implement"); diff --git a/apps/web/src/components/board/BoardHeaderControls.tsx b/apps/web/src/components/board/BoardHeaderControls.tsx index 19db22876d4..e3e4301d0bc 100644 --- a/apps/web/src/components/board/BoardHeaderControls.tsx +++ b/apps/web/src/components/board/BoardHeaderControls.tsx @@ -1,4 +1,4 @@ -import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "~/components/ui/button"; @@ -9,12 +9,6 @@ export interface BoardHeaderLane { readonly name: string; } -export interface BoardHeaderProject { - readonly id: string; - readonly cwd: string; - readonly name: string; -} - export interface NewTicketInput { readonly title: string; readonly initialLane: string; @@ -26,15 +20,11 @@ export const getDefaultInitialLane = (lanes: ReadonlyArray): st export function BoardHeaderControls({ boardId, lanes, - project, onCreateTicket, - onRegisterBoard, }: { readonly boardId: string | null; readonly lanes: ReadonlyArray; - readonly project?: BoardHeaderProject | undefined; readonly onCreateTicket: (input: NewTicketInput) => void; - readonly onRegisterBoard: () => void; }) { const [title, setTitle] = useState(""); const [initialLane, setInitialLane] = useState(() => getDefaultInitialLane(lanes) ?? ""); @@ -48,20 +38,9 @@ export function BoardHeaderControls({ const trimmedTitle = title.trim(); const canCreateTicket = Boolean(boardId && initialLane && trimmedTitle); - const canRegisterBoard = Boolean(boardId && project); return (
-
{ diff --git a/apps/web/src/routes/-boardRouteState.test.ts b/apps/web/src/routes/-boardRouteState.test.ts new file mode 100644 index 00000000000..560ae8c1100 --- /dev/null +++ b/apps/web/src/routes/-boardRouteState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getBoardRouteEmptyState } from "./_chat.$environmentId.board"; + +describe("getBoardRouteEmptyState", () => { + it("distinguishes no selection from a missing requested board", () => { + expect(getBoardRouteEmptyState({ boardId: null, boardLoadError: null })).toEqual({ + title: "No board selected.", + description: null, + }); + + expect( + getBoardRouteEmptyState({ + boardId: "project-1__missing" as never, + boardLoadError: "Workflow board project-1__missing was not found", + }), + ).toEqual({ + title: "Board not found.", + description: "Workflow board project-1__missing was not found", + }); + }); +}); diff --git a/apps/web/src/routes/_chat.$environmentId.board.tsx b/apps/web/src/routes/_chat.$environmentId.board.tsx index 836e0ad65ec..b407602e2aa 100644 --- a/apps/web/src/routes/_chat.$environmentId.board.tsx +++ b/apps/web/src/routes/_chat.$environmentId.board.tsx @@ -23,6 +23,32 @@ export interface BoardRouteSearch { readonly boardId?: string | undefined; } +export interface BoardRouteEmptyState { + readonly title: string; + readonly description: string | null; +} + +export function getBoardRouteEmptyState(input: { + readonly boardId: BoardId | null; + readonly boardLoadError: string | null; +}): BoardRouteEmptyState | null { + if (!input.boardId) { + return { + title: "No board selected.", + description: null, + }; + } + + if (input.boardLoadError) { + return { + title: "Board not found.", + description: input.boardLoadError, + }; + } + + return null; +} + const parseBoardRouteSearch = (search: Record): BoardRouteSearch => { const boardId = typeof search.boardId === "string" ? search.boardId.trim() : ""; return boardId ? { boardId } : {}; @@ -35,11 +61,44 @@ function WorkflowBoardRouteView() { const [ticketDetail, setTicketDetail] = useState(null); const [ticketDetailError, setTicketDetailError] = useState(null); const [ticketDetailReloadKey, setTicketDetailReloadKey] = useState(0); + const [boardLoadError, setBoardLoadError] = useState(null); const environmentId = useMemo(() => EnvironmentId.make(rawEnvironmentId), [rawEnvironmentId]); const boardId = useMemo(() => (rawBoardId ? BoardId.make(rawBoardId) : null), [rawBoardId]); const state = useStore((store) => boardId ? (store.boardStateById[boardId] ?? emptyBoardState) : emptyBoardState, ); + const emptyState = getBoardRouteEmptyState({ boardId, boardLoadError }); + + useEffect(() => { + setBoardLoadError(null); + if (!boardId) { + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) { + setBoardLoadError("Environment API unavailable."); + return; + } + + let cancelled = false; + void api.workflow.getBoard({ boardId }).then( + () => { + if (!cancelled) { + setBoardLoadError(null); + } + }, + (error: unknown) => { + if (!cancelled) { + setBoardLoadError(errorMessage(error)); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [boardId, environmentId]); useEffect(() => { if (!boardId) { @@ -180,15 +239,19 @@ function WorkflowBoardRouteView() { boardId={boardId} lanes={state.lanes} onCreateTicket={handleCreateTicket} - onRegisterBoard={() => undefined} /> - {boardId ? ( - - ) : ( -
- No board selected. + {emptyState ? ( +
+
+
{emptyState.title}
+ {emptyState.description ? ( +
{emptyState.description}
+ ) : null} +
+ ) : ( + )}
From a2c9b4e4ad9d65caea73aeb1df6e949c8d16d30b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 17:33:12 -0400 Subject: [PATCH 079/295] docs: add script-steps (v2-A) design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First v2 sub-project: first-class script steps. Incorporates GPT-5.5 design review — generic ScriptCommandRunner over TerminalManager (subscribe-before- write, wrapped exit, threadId+terminalId identity), new blocked step semantics, shared prepared-worktree executor refactor, per-project trust table+RPC, workflow_script_run model, step cancel path, restart recovery, and a read-only drill-in terminal viewer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-07-script-steps-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-script-steps-design.md diff --git a/docs/superpowers/specs/2026-06-07-script-steps-design.md b/docs/superpowers/specs/2026-06-07-script-steps-design.md new file mode 100644 index 00000000000..9f9fa5ed618 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-script-steps-design.md @@ -0,0 +1,114 @@ +# Workflow Boards v2-A — Script Steps — Design + +- **Status:** Draft for review (incorporates GPT-5.5 design review) +- **Date:** 2026-06-07 +- **Author:** Chris + Claude (brainstorming session) +- **Builds on:** Workflow Boards v1 (`apps/server/src/workflow/**`, `packages/contracts/src/workflow.ts`). First of the v2 sub-projects (script steps → smart routing → WIP → visual editor). + +## 1. Overview + +Add a first-class **`script` step** to workflow pipelines: it runs a shell command in the ticket's git worktree, streams output live, enforces a timeout, can be cancelled, and routes on exit code. This lets pipelines deterministically gate on tests/lint (`pnpm test && pnpm lint`). + +### Locked decisions +1. **Trust:** simple **per-project** trust (a boolean per project; not file-hash). Untrusted → the step is **blocked** with a "Trust this project & run" affordance. +2. **Command:** a **shell string** (`run: "pnpm test && pnpm lint"`), executed behind the trust gate. +3. **Output:** reuse the **terminal's** durable (capped) store + live stream. Workflow events record start/exit only — not output. +4. **First step:** a `script` first step still ensures the worktree + runs the setup gate (same as agent steps). + +## 2. Cross-cutting prerequisite — `blocked` step semantics + +The engine's internal `StepResult` already has `"blocked"`, but `StepOutcome` and `StepRunStatus` do **not** (`packages/contracts/src/workflow.ts:251-261`, `:136-145`), so a blocked step cannot be represented or projected. This must be fixed first; it also cleans up agent/approval blocking. + +- Add `{ _tag: "blocked", reason }` to **`StepOutcome`**; add `blocked` to **`StepRunStatus`**. +- Add a **`StepBlocked { stepRunId, reason }`** workflow event; the projection sets `projection_step_run.status = 'blocked'` and the ticket to `blocked`. +- Engine routing: a blocked step ends the pipeline with `PipelineCompleted { result: "blocked" }` and routes via the lane's `on.blocked` (falling back to `TicketBlocked` if unrouted), matching the existing `route()`. + +## 3. Schema + +Add `ScriptStep` to the `WorkflowStep` union and **every** step-type union that currently lists only `agent | approval` (`WorkflowStep` `:69-83`, `StepStarted.payload.stepType` `:198-206`, `WorkflowStepRunView.stepType` `:322-328`); update the workflow JSON tests that assert `type:"script"` is rejected (`workflow.test.ts:80-96`). + +```jsonc +{ "key": "tests", "type": "script", + "run": "pnpm test && pnpm lint", // shell string (required) + "timeout": "10 minutes", // optional; validated as Duration.Input; default 10m + "cwd": ".", // optional; relative to the ticket worktree + "allowFailure": false } // optional; true → non-zero exit routes success +``` + +`timeout` is validated as a parseable `Duration.Input` at decode + lint time, not "any string". + +## 4. Executor refactor — shared prepared-worktree step + +Today `WorkflowEngine.runStep` handles `approval` inline and delegates everything else to `StepExecutor`; `RealStepExecutor` returns `completed` for non-agent steps and holds the agent-only worktree/setup/lease/checkpoint logic (`WorkflowEngine.ts:260-365`, `RealStepExecutor.ts:49-126`). Do **not** add script worktree logic to the engine. + +- Extract a **`PreparedWorktreeStep`** helper from `RealStepExecutor`: `ensureWorktree` → baseline checkpoint → **setup gate** → acquire **worktree lease** → run an inner step body → capture pre/post checkpoints → release lease (`ensuring`). +- The `StepExecutor` (or a small dispatcher in the executor layer) routes by `step.type`: `agent` → the existing agent body; `script` → the new `ScriptStepExecutor` body. The engine still just calls `executor.execute(ctx)`. +- This keeps worktree/lease/setup/checkpoint logic in one place and shared by agent + script steps. + +## 5. Script command runner (generic, over TerminalManager) + +`TerminalManager.open` starts an **interactive shell** in a cwd; it does not accept a command (`packages/contracts/src/terminal.ts:39-46`, `Manager.ts:1633-1635`). Setup runs a command by opening a terminal and writing `${cmd}\r` (`ProjectSetupScriptRunner.ts:54-65`) — but a bare command leaves the shell open. The private setup `awaitExit` subscribes *after* launch, filters only by `terminalId`, ignores `threadId`, and doesn't close on timeout (`SetupRunService.ts:107-145`). + +Build a **`ScriptCommandRunner`** service (generic; setup can later adopt it): +- **Identity:** each run gets a synthetic **`scriptThreadId`** + a `terminalId` (so the terminal is addressable as `threadId+terminalId` everywhere). +- **Subscribe before write:** subscribe to terminal events (filtered by `threadId AND terminalId`) **before** writing the command, to avoid the launch/exit race. +- **Wrapped command:** open the terminal in the worktree `cwd`, then write a wrapper that runs the user `run` and **exits the shell with its exit code**, e.g. write `` `${run}\nexit $?\r` `` (or a one-shot `sh -lc ''; exit` form). Confirm the shell/quoting during planning. +- **Await:** resolve on `exited{ exitCode, signal }`; treat `closed` as **cancellation**; on **timeout** → `terminal.close` then resolve as timed-out; return `{ exitCode, signal, outcome: "exited" | "timeout" | "cancelled" }`. +- **Cleanup:** `ensuring`/interrupt → `terminal.close` (process-group SIGTERM→SIGKILL) so an interrupted step kills the process. + +## 6. `ScriptStepExecutor` + +Inside the prepared-worktree wrapper (lease held): +1. **Trust gate:** if the project is not trusted (§7), return `{ _tag: "blocked", reason: "Project not trusted to run scripts" }` (do **not** run anything). +2. Mint `scriptRunId` + `scriptThreadId` + `terminalId`; commit `ScriptStepStarted{ scriptRunId, stepRunId, scriptThreadId, terminalId }`. +3. Run via `ScriptCommandRunner` with the step's `run`, `cwd` (resolved under the worktree), and `timeout` (default 10m). +4. Commit `ScriptStepExited{ scriptRunId, exitCode, signal, outcome }`. +5. Map to `StepOutcome`: `exited` + code 0 → `completed`; `exited` + non-zero → `completed` if `allowFailure` else `failed`; `timeout` → `failed` ("timed out"); `cancelled` → `failed` ("cancelled"). Exit code is recorded for future predicates (v2-B). + +## 7. Trust (per-project) + +No per-project settings exist today (server settings are global JSON; `projection_projects` is event-derived). Add: +- **Migration `039_WorkflowProjectTrust.ts`** (after `038`): `workflow_project_trust(project_id TEXT PRIMARY KEY, trusted_at TEXT NOT NULL)`. Register it in `Migrations.ts` (`:48-53`, `:98-103`). +- **`ProjectScriptTrust` service:** `isTrusted(projectId)`, `setTrusted(projectId, trusted)`. +- **RPC `workflow.setProjectScriptTrust({ projectId, trusted })`** (scope `AuthWorkflowOperateScope`), wired through contracts (`workflow.ts` + `rpc.ts`), `ipc.ts`, `wsRpcClient`, `environmentApi`, `ws.ts` `RPC_REQUIRED_SCOPE`, and the workflow handler map — mirroring the board-creation RPC wiring. +- **Grant flow:** untrusted script step → ticket `blocked` with the reason; the drill-in shows **"Trust this project & run"** → `setProjectScriptTrust({ trusted: true })` → user re-runs the lane (`workflow.runLane`). No auto-resume in this sub-project. + +## 8. Durable `ScriptRun` model + +`StepRun` has no execution-reference today and `projection_step_run` carries only identity/status/timestamps + checkpoint refs (`033`, `038`). Add: +- **Migration `040_WorkflowScriptRun.ts`:** `workflow_script_run(script_run_id PK, step_run_id UNIQUE, ticket_id, script_thread_id, terminal_id, status, exit_code, signal, started_at, finished_at)`. +- Workflow events `ScriptStepStarted` / `ScriptStepExited` (+ a `ScriptStepCancelled` if modeled separately) project into that table (new `ProjectionScriptRuns`). +- **Ticket detail** (`WorkflowReadModel.getTicketDetail`, `:113-123`) left-joins `workflow_script_run` so a script `StepRun` view carries `{ scriptThreadId, terminalId, status, exitCode, signal }`. Output is **not** stored here — it lives in the terminal's durable store keyed by `scriptThreadId+terminalId`. + +## 9. Cancellation + +No step-cancel path exists in v1 (only manual-move interruption of the pipeline fiber). Add: +- **RPC `workflow.cancelStep({ stepRunId })`** (operate scope): finds the running pipeline fiber for the step's ticket, interrupts the running step (the `ScriptCommandRunner`'s `ensuring` does `terminal.close`), commits `ScriptStepExited{ outcome: "cancelled" }` → `StepFailed`/blocked routing, and releases the lease. +- The await logic treats a `closed` terminal event as cancellation so the runner resolves rather than hanging. +- The drill-in **Cancel** button calls this RPC. + +## 10. Restart recovery + +Terminal processes are killed on server shutdown (`Manager.ts:1855-1881`); v1 recovery (`WorkflowRecovery`) only handles provider dispatch, durable approvals, and lease release for already-terminal steps. Extend recovery to: +- Scan `workflow_script_run` rows with non-terminal `status` (running) → mark `failed` (interrupted) / `timed_out`, commit `ScriptStepExited{ outcome }` + the step terminal event, drive `completeRecoveredStep` so the pipeline routes, and release the worktree lease. + +## 11. UI drill-in + +- A `script` `StepRun` renders a **read-only terminal/log viewer** keyed by `scriptThreadId+terminalId` that displays the persisted terminal history and live output **without ever spawning a new shell** (the existing `TerminalViewport` opens a shell when attaching with `cwd` and no live session — `ThreadTerminalDrawer.tsx:661-672`, `Manager.ts:2017-2029`). Extract/add a **history-only attach** (snapshot + live append, no `cwd`-driven spawn). +- Badges: running / exit-code / timed-out / cancelled / blocked. +- Buttons: **Cancel** (running, §9) and **"Trust this project & run"** (blocked-on-trust, §7). + +## 12. Testing + +- **Command runner:** real temp git worktree; a fast `run` exits 0 → `completed`; non-zero → `failed` (and `allowFailure` → `completed`); a sleep + short `timeout` → `timed_out` and the terminal is closed (process killed); cancel mid-run → `cancelled` + terminal closed. Subscribe-before-write avoids the race (a near-instant command still reports its exit). +- **Trust:** untrusted project → `blocked` outcome + `TicketBlocked`; after `setProjectScriptTrust(true)` + re-run → executes. +- **Blocked semantics:** `StepOutcome.blocked` projects `projection_step_run.status='blocked'`; routes via `on.blocked`. +- **Schema/lint:** `script` decodes; bad `timeout` rejected; unknown step type still rejected. +- **Projection:** `ScriptStepStarted/Exited` populate `workflow_script_run`; ticket detail join exposes terminal identity. +- **Recovery:** a seeded running `workflow_script_run` at restart → marked failed/interrupted, pipeline routed, lease released. + +## 13. Open questions + +- **Shell wrapper exactness:** the precise write to make the configured shell run `run` and exit with its code (newline + `exit $?` vs a one-shot `sh -lc`), incl. quoting — confirm against `ProjectSetupScriptRunner` + the terminal's shell config during planning. +- **History-only terminal attach:** add a new read-only attach API vs. a guarded `TerminalViewport` prop that forbids spawning — pick during planning. +- **Setup runner convergence:** whether to migrate `SetupRunService` onto the new `ScriptCommandRunner` now or leave it (recommend leave; just don't reuse its private `awaitExit`). From d841d1f6974cacd76b294e840b41eb6636b4588c Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 17:42:18 -0400 Subject: [PATCH 080/295] docs: fold GPT-5.5 spec review into script-steps design - Thread `blocked` through detection/recovery/lease/projection/read-model (+ blockedReason), not just the schema. - Trust gates BEFORE setup (pre-setup guard) so untrusted runs no shell. - Cancellation is cooperative via a ScriptCancelRegistry + terminal.close (not pipeline-fiber interrupt, which commits nothing); full cancelStep RPC wiring enumerated. - History-only terminal: dedicated server attach/read API + read-only UI (guarded TerminalViewport prop is insufficient; attach w/o cwd errors). - timeout via Schema.DurationFromString; cwd containment (realpath under worktree); weaken process-group claim to PTY-shell kill; drop separate ScriptStepCancelled event. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-07-script-steps-design.md | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-script-steps-design.md b/docs/superpowers/specs/2026-06-07-script-steps-design.md index 9f9fa5ed618..6c3f4594281 100644 --- a/docs/superpowers/specs/2026-06-07-script-steps-design.md +++ b/docs/superpowers/specs/2026-06-07-script-steps-design.md @@ -17,11 +17,13 @@ Add a first-class **`script` step** to workflow pipelines: it runs a shell comma ## 2. Cross-cutting prerequisite — `blocked` step semantics -The engine's internal `StepResult` already has `"blocked"`, but `StepOutcome` and `StepRunStatus` do **not** (`packages/contracts/src/workflow.ts:251-261`, `:136-145`), so a blocked step cannot be represented or projected. This must be fixed first; it also cleans up agent/approval blocking. +The engine's internal `StepResult` already has `"blocked"`, but `StepOutcome` and `StepRunStatus` do **not** (`packages/contracts/src/workflow.ts:251-261`, `:136-145`), so a blocked step cannot be represented or projected. This must be fixed first; it also cleans up agent/approval blocking. **It is not just a schema add** — `blocked` must be threaded through every place that currently special-cases `StepCompleted | StepFailed`: -- Add `{ _tag: "blocked", reason }` to **`StepOutcome`**; add `blocked` to **`StepRunStatus`**. -- Add a **`StepBlocked { stepRunId, reason }`** workflow event; the projection sets `projection_step_run.status = 'blocked'` and the ticket to `blocked`. -- Engine routing: a blocked step ends the pipeline with `PipelineCompleted { result: "blocked" }` and routes via the lane's `on.blocked` (falling back to `TicketBlocked` if unrouted), matching the existing `route()`. +- **Contracts:** add `{ _tag: "blocked", reason }` to `StepOutcome`; add `blocked` to `StepRunStatus`; add a **`StepBlocked { stepRunId, reason }`** event to the `WorkflowEvent` union (the event store/projection use the union directly, so the type must exist before it can be committed/replayed); add a `blockedReason` field to `WorkflowStepRunView` (today it has only `waitingReason`, `:322-328`). +- **Engine:** treat `StepBlocked` as a terminal step event in `isTerminalStepEvent`/the terminal-detection used by `runStep` and `completeRecoveredStep` (`WorkflowEngine.ts:144-152`, `:651-676`); add a `blocked` arm to `RecoveredStepResult`; route a blocked pipeline as `PipelineCompleted { result: "blocked" }` via the lane's `on.blocked` (fallback `TicketBlocked`). +- **Recovery:** include `StepBlocked` in `isTerminalStepEvent` (`WorkflowRecovery.ts:47-50`) and the lease-release "terminal step" query (`:198-204`) so a blocked step's lease is released. +- **Projection + read model:** `StepBlocked` sets `projection_step_run.status='blocked'` + persists the reason; ticket detail selects it and exposes `blockedReason` (`WorkflowReadModel.ts:113-123`). +- **Tests:** blocked outcome → projection status + `on.blocked` routing + lease release + recovery. ## 3. Schema @@ -35,14 +37,15 @@ Add `ScriptStep` to the `WorkflowStep` union and **every** step-type union that "allowFailure": false } // optional; true → non-zero exit routes success ``` -`timeout` is validated as a parseable `Duration.Input` at decode + lint time, not "any string". +`timeout` is a real contract schema, not the TS `Duration.Input` type: use `Schema.DurationFromString` (or a local `ScriptTimeout` schema mirroring `apps/server/src/cli/config.ts:447-465`) so it decodes to an actual `Duration` and rejects garbage at decode time. ## 4. Executor refactor — shared prepared-worktree step Today `WorkflowEngine.runStep` handles `approval` inline and delegates everything else to `StepExecutor`; `RealStepExecutor` returns `completed` for non-agent steps and holds the agent-only worktree/setup/lease/checkpoint logic (`WorkflowEngine.ts:260-365`, `RealStepExecutor.ts:49-126`). Do **not** add script worktree logic to the engine. -- Extract a **`PreparedWorktreeStep`** helper from `RealStepExecutor`: `ensureWorktree` → baseline checkpoint → **setup gate** → acquire **worktree lease** → run an inner step body → capture pre/post checkpoints → release lease (`ensuring`). -- The `StepExecutor` (or a small dispatcher in the executor layer) routes by `step.type`: `agent` → the existing agent body; `script` → the new `ScriptStepExecutor` body. The engine still just calls `executor.execute(ctx)`. +- Extract a **`PreparedWorktreeStep`** helper from `RealStepExecutor`, ordered: `ensureWorktree` → baseline checkpoint → **pre-setup guard (optional)** → **setup gate** → acquire **worktree lease** → run an inner step body → capture pre/post checkpoints → release lease (`ensuring`). +- **Trust gates before setup (finding from review):** project setup itself runs a shell (`SetupRunService`→`ProjectSetupScriptRunner`). So for a `script` step the trust check is the **pre-setup guard** — an untrusted project returns `{ _tag: "blocked" }` **before** `ensureWorktree`-then-setup runs any shell for that step. Agent steps pass the guard (no trust needed). (`ensureWorktree`/baseline are pure git, not shell, so running them before the guard is fine; the guard must precede the setup gate.) +- The `StepExecutor` (or a small dispatcher in the executor layer) routes by `step.type`: `agent` → the existing agent body; `script` → the new `ScriptStepExecutor` body, with the script trust guard supplied as the pre-setup guard. The engine still just calls `executor.execute(ctx)`. - This keeps worktree/lease/setup/checkpoint logic in one place and shared by agent + script steps. ## 5. Script command runner (generic, over TerminalManager) @@ -54,14 +57,14 @@ Build a **`ScriptCommandRunner`** service (generic; setup can later adopt it): - **Subscribe before write:** subscribe to terminal events (filtered by `threadId AND terminalId`) **before** writing the command, to avoid the launch/exit race. - **Wrapped command:** open the terminal in the worktree `cwd`, then write a wrapper that runs the user `run` and **exits the shell with its exit code**, e.g. write `` `${run}\nexit $?\r` `` (or a one-shot `sh -lc ''; exit` form). Confirm the shell/quoting during planning. - **Await:** resolve on `exited{ exitCode, signal }`; treat `closed` as **cancellation**; on **timeout** → `terminal.close` then resolve as timed-out; return `{ exitCode, signal, outcome: "exited" | "timeout" | "cancelled" }`. -- **Cleanup:** `ensuring`/interrupt → `terminal.close` (process-group SIGTERM→SIGKILL) so an interrupted step kills the process. +- **Cleanup:** `ensuring`/interrupt → `terminal.close`, which kills the **PTY shell** via the adapter (SIGTERM→SIGKILL; `Manager.ts:1073-1104`, `PTY.ts:27-33`). Note this does **not** guarantee reaping the full process group/grandchildren — a normal shell exit reaps its children, but a hard kill can orphan child processes. True process-group kill is a follow-up hardening (extend the PTY adapter to signal the group); v2-A relies on shell-exit + PTY kill. ## 6. `ScriptStepExecutor` Inside the prepared-worktree wrapper (lease held): 1. **Trust gate:** if the project is not trusted (§7), return `{ _tag: "blocked", reason: "Project not trusted to run scripts" }` (do **not** run anything). 2. Mint `scriptRunId` + `scriptThreadId` + `terminalId`; commit `ScriptStepStarted{ scriptRunId, stepRunId, scriptThreadId, terminalId }`. -3. Run via `ScriptCommandRunner` with the step's `run`, `cwd` (resolved under the worktree), and `timeout` (default 10m). +3. Run via `ScriptCommandRunner` with the step's `run`, `cwd`, and `timeout` (default 10m). **Containment:** resolve + `realPath` the `cwd` under the worktree and **reject any escape** (`..`, symlink, absolute path outside) before passing an absolute cwd to the terminal — `TerminalManager.assertValidCwd` (`Manager.ts:1313-1329`) only checks existence, not containment. 4. Commit `ScriptStepExited{ scriptRunId, exitCode, signal, outcome }`. 5. Map to `StepOutcome`: `exited` + code 0 → `completed`; `exited` + non-zero → `completed` if `allowFailure` else `failed`; `timeout` → `failed` ("timed out"); `cancelled` → `failed` ("cancelled"). Exit code is recorded for future predicates (v2-B). @@ -77,15 +80,17 @@ No per-project settings exist today (server settings are global JSON; `projectio `StepRun` has no execution-reference today and `projection_step_run` carries only identity/status/timestamps + checkpoint refs (`033`, `038`). Add: - **Migration `040_WorkflowScriptRun.ts`:** `workflow_script_run(script_run_id PK, step_run_id UNIQUE, ticket_id, script_thread_id, terminal_id, status, exit_code, signal, started_at, finished_at)`. -- Workflow events `ScriptStepStarted` / `ScriptStepExited` (+ a `ScriptStepCancelled` if modeled separately) project into that table (new `ProjectionScriptRuns`). +- Two workflow events only — `ScriptStepStarted` and `ScriptStepExited{ exitCode, signal, outcome: "exited" | "timeout" | "cancelled" }` — added to the contracts `WorkflowEvent` union and projected by a new `ProjectionScriptRuns` into that table. (No separate `ScriptStepCancelled` event — cancellation is just `outcome: "cancelled"`.) - **Ticket detail** (`WorkflowReadModel.getTicketDetail`, `:113-123`) left-joins `workflow_script_run` so a script `StepRun` view carries `{ scriptThreadId, terminalId, status, exitCode, signal }`. Output is **not** stored here — it lives in the terminal's durable store keyed by `scriptThreadId+terminalId`. ## 9. Cancellation -No step-cancel path exists in v1 (only manual-move interruption of the pipeline fiber). Add: -- **RPC `workflow.cancelStep({ stepRunId })`** (operate scope): finds the running pipeline fiber for the step's ticket, interrupts the running step (the `ScriptCommandRunner`'s `ensuring` does `terminal.close`), commits `ScriptStepExited{ outcome: "cancelled" }` → `StepFailed`/blocked routing, and releases the lease. -- The await logic treats a `closed` terminal event as cancellation so the runner resolves rather than hanging. -- The drill-in **Cancel** button calls this RPC. +**Not** fiber interruption: interrupting the pipeline fiber (`WorkflowEngine.ts:219-234`) tears down the whole pipeline and interrupt-only causes are swallowed (`:379-382`), so no `ScriptStepExited`/`StepFailed`/`PipelineCompleted`/routing would commit. Cancellation is **cooperative**: + +- A **`ScriptCancelRegistry`** holds a handle per running script, keyed by `stepRunId` (registered on start, removed on exit). `ScriptCommandRunner`'s await treats a `closed` terminal event as cancellation and resolves `{ outcome: "cancelled" }` rather than hanging. +- **RPC `workflow.cancelStep({ stepRunId })`** (operate scope) looks up the handle and calls `terminal.close({ threadId: scriptThreadId, terminalId })`. The runner then resolves cancelled, and the **normal `runStep` flow continues** — committing `ScriptStepExited{ outcome:"cancelled" }` → `StepFailed` and routing, releasing the lease via the prepared-wrapper `ensuring`. No partial/torn-down state. +- Manual-move supersession (existing) is unchanged. +- **Wire `cancelStep` through every layer** (mirroring the trust RPC, §7): `WORKFLOW_WS_METHODS` (`workflow.ts:11-22`), `rpc.ts` (`:574-596`, `:687-696`), `ws.ts` scope (`:155-164`), `wsRpcClient` (`:173-182`, `:388-411`), `environmentApi` (`:61-73`), `ipc.ts` (`:626-656`), `WorkflowEngineShape`, the handler deps, and test mocks. The drill-in **Cancel** button calls it. ## 10. Restart recovery @@ -94,7 +99,9 @@ Terminal processes are killed on server shutdown (`Manager.ts:1855-1881`); v1 re ## 11. UI drill-in -- A `script` `StepRun` renders a **read-only terminal/log viewer** keyed by `scriptThreadId+terminalId` that displays the persisted terminal history and live output **without ever spawning a new shell** (the existing `TerminalViewport` opens a shell when attaching with `cwd` and no live session — `ThreadTerminalDrawer.tsx:661-672`, `Manager.ts:2017-2029`). Extract/add a **history-only attach** (snapshot + live append, no `cwd`-driven spawn). +- A `script` `StepRun` renders a **read-only terminal/log viewer** keyed by `scriptThreadId+terminalId`. A guarded `TerminalViewport` prop is **not** sufficient: `attachStream` without `cwd` **errors** when no live session exists (e.g. after the script exits / after restart — `Manager.ts:2017-2029`), and `TerminalViewport` requires `cwd`, writes input, and always sends `cwd` (`ThreadTerminalDrawer.tsx:254-269`, `:661-672`). So v2-A adds: + - a **server-side history-only attach/read API** that returns the persisted terminal history (+ live append while running) **without ever spawning a shell** (no `cwd`), and + - a **separate read-only UI component** (no keyboard input, no `cwd`) that consumes it. - Badges: running / exit-code / timed-out / cancelled / blocked. - Buttons: **Cancel** (running, §9) and **"Trust this project & run"** (blocked-on-trust, §7). @@ -110,5 +117,5 @@ Terminal processes are killed on server shutdown (`Manager.ts:1855-1881`); v1 re ## 13. Open questions - **Shell wrapper exactness:** the precise write to make the configured shell run `run` and exit with its code (newline + `exit $?` vs a one-shot `sh -lc`), incl. quoting — confirm against `ProjectSetupScriptRunner` + the terminal's shell config during planning. -- **History-only terminal attach:** add a new read-only attach API vs. a guarded `TerminalViewport` prop that forbids spawning — pick during planning. +- (Resolved in §11: history-only terminal attach is a dedicated server API + read-only UI component, not a guarded `TerminalViewport` prop.) - **Setup runner convergence:** whether to migrate `SetupRunService` onto the new `ScriptCommandRunner` now or leave it (recommend leave; just don't reuse its private `awaitExit`). From ff4abf1b59fcd86321735ef094f2ce7607927e9b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 7 Jun 2026 18:54:51 -0400 Subject: [PATCH 081/295] checkpoint: board-creation polish + script-steps unit-1 (blocked semantics) Combined checkpoint (two uncommitted bodies were intertwined at hunk level, no interactive git available). All green: typecheck, contracts (163), server workflow (82). - board-creation polish: create-ticket dialog (title+description) + pipelineStepCount on board lanes + browser test. - script-steps unit-1 (GPT-5.5/Codex, TDD): `blocked` step semantics threaded through StepOutcome/StepRunStatus/StepBlocked event/blockedReason, engine + recovery terminal-detection + lease-release, projection, read-model. - process artifacts: codex build/fix prompts, review docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + .../Layers/WorkflowEngine.integration.test.ts | 69 +++++++++ .../src/workflow/Layers/WorkflowEngine.ts | 25 ++- .../Layers/WorkflowProjectionPipeline.test.ts | 72 +++++++++ .../Layers/WorkflowProjectionPipeline.ts | 13 ++ .../workflow/Layers/WorkflowReadModel.test.ts | 61 ++++++++ .../src/workflow/Layers/WorkflowReadModel.ts | 6 +- .../workflow/Layers/WorkflowRecovery.test.ts | 53 +++++++ .../src/workflow/Layers/WorkflowRecovery.ts | 8 +- .../Layers/WorkflowRpcHandlers.test.ts | 14 +- .../workflow/Layers/WorkflowRpcHandlers.ts | 2 + .../src/workflow/Services/WorkflowEngine.ts | 3 +- .../workflow/Services/WorkflowReadModel.ts | 1 + .../board/BoardHeaderControls.browser.tsx | 40 +++++ .../board/BoardHeaderControls.test.tsx | 15 +- .../components/board/BoardHeaderControls.tsx | 145 ++++++++++++++---- .../src/components/board/BoardView.test.tsx | 4 +- apps/web/src/components/board/LaneColumn.tsx | 1 + .../components/board/TicketDrawer.test.tsx | 17 ++ .../web/src/components/board/TicketDrawer.tsx | 10 +- .../src/routes/_chat.$environmentId.board.tsx | 7 +- apps/web/src/workflow/boardState.test.ts | 4 +- apps/web/src/workflow/boardState.ts | 1 + .../plans/2026-06-07-board-creation-ux.md | 84 +++++++--- .../2026-06-07-board-creation-ux-design.md | 39 +++-- .../specs/2026-06-07-script-steps-design.md | 21 ++- packages/contracts/src/workflow.test.ts | 42 +++++ packages/contracts/src/workflow.ts | 9 ++ packages/contracts/src/workflowRpc.test.ts | 2 +- 29 files changed, 686 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/components/board/BoardHeaderControls.browser.tsx diff --git a/.gitignore b/.gitignore index ef6067824f2..81a88e3d73b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ node_modules/ *.log .env* !.env.example +.superpowers/ diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts index 1c823bc84aa..70f68b6131f 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -147,6 +147,75 @@ failLayer("WorkflowEngine integration failure path", (it) => { ); }); +const blockedDefinition = { + name: "blocked-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs", blocked: "trust" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "trust", name: "Trust", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const blockedLayer = it.layer( + baseLayer( + makeStubStepExecutor({ + default: { _tag: "blocked", reason: "Project not trusted to run scripts" } as never, + }), + ), +); + +blockedLayer("WorkflowEngine integration blocked path", (it) => { + it.effect("blocked step routes through the lane blocked target and records its reason", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-blocked" as never, blockedDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-blocked" as never, + title: "Trust", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "trust"); + assert.equal(detail?.ticket.currentLaneKey, "trust"); + assert.equal(detail?.steps[0]?.status, "blocked"); + assert.equal(detail?.steps[0]?.blockedReason, "Project not trusted to run scripts"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isTrue( + events.some( + (event) => + event.type === "StepBlocked" && + event.payload.reason === "Project not trusted to run scripts", + ), + ); + assert.isTrue( + events.some( + (event) => event.type === "PipelineCompleted" && event.payload.result === "blocked", + ), + ); + }), + ); +}); + const explodingExecutor = Layer.succeed(StepExecutor, { execute: () => Effect.fail(new WorkflowEventStoreError({ message: "executor exploded" })) as never, diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts index 14a9b80c2fc..8c6a003f96a 100644 --- a/apps/server/src/workflow/Layers/WorkflowEngine.ts +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -147,7 +147,9 @@ const make = Effect.gen(function* () { ) => events.some( (event) => - (event.type === "StepCompleted" || event.type === "StepFailed") && + (event.type === "StepCompleted" || + event.type === "StepFailed" || + event.type === "StepBlocked") && event.payload.stepRunId === stepRunId, ); @@ -359,6 +361,14 @@ const make = Effect.gen(function* () { }); return "failed"; } + if (outcome._tag === "blocked") { + yield* commit({ + type: "StepBlocked", + ticketId, + payload: { stepRunId, reason: outcome.reason }, + }); + return "blocked"; + } yield* commit({ type: "StepCompleted", ticketId, payload: { stepRunId } }); return "completed"; @@ -655,15 +665,24 @@ const make = Effect.gen(function* () { ticketId: recovered.stepStarted.ticketId, payload: { stepRunId }, }); - } else { + } else if (result._tag === "failed") { yield* commit({ type: "StepFailed", ticketId: recovered.stepStarted.ticketId, payload: { stepRunId, error: result.error }, }); + } else { + yield* commit({ + type: "StepBlocked", + ticketId: recovered.stepStarted.ticketId, + payload: { stepRunId, reason: result.reason }, + }); } } + const recoveredResult: PipelineResult = + result._tag === "completed" ? "success" : result._tag === "blocked" ? "blocked" : "failure"; + yield* completePipelineFrom( recovered.stepStarted.ticketId, boardId, @@ -672,7 +691,7 @@ const make = Effect.gen(function* () { recovered.pipelineStarted.payload.pipelineRunId, steps, result._tag === "completed" ? currentStepIndex + 1 : steps.length, - result._tag === "completed" ? "success" : "failure", + recoveredResult, ); }); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts index f53cc60dbd3..9ae1d51e528 100644 --- a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts @@ -121,4 +121,76 @@ layer("WorkflowProjectionPipeline", (it) => { assert.equal(step[0]?.waitingReason, "which API?"); }), ); + + it.effect("projects a blocked step as terminal with its blocked reason", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-blocked" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "blocked-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Blocked" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "blocked-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-blocked" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-blocked" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "blocked-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-blocked" as never, + stepRunId: "sr-blocked" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepBlocked", + eventId: "blocked-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-blocked" as never, + reason: "Project not trusted to run scripts", + }, + } as never); + + const rows = yield* sql<{ + readonly blockedReason: string | null; + readonly finishedAt: string | null; + readonly status: string; + }>` + SELECT + status, + error AS "blockedReason", + finished_at AS "finishedAt" + FROM projection_step_run + WHERE step_run_id = 'sr-blocked' + `; + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.blockedReason, "Project not trusted to run scripts"); + assert.isNotNull(rows[0]?.finishedAt ?? null); + }), + ); }); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts index 7b3be995975..00b32dc7ae9 100644 --- a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -177,6 +177,7 @@ const make = Effect.gen(function* () { yield* sql` UPDATE projection_step_run SET status = 'completed', + waiting_reason = NULL, finished_at = ${event.occurredAt} WHERE step_run_id = ${event.payload.stepRunId} `; @@ -186,12 +187,24 @@ const make = Effect.gen(function* () { yield* sql` UPDATE projection_step_run SET status = 'failed', + waiting_reason = NULL, error = ${event.payload.error}, finished_at = ${event.occurredAt} WHERE step_run_id = ${event.payload.stepRunId} `; break; } + case "StepBlocked": { + yield* sql` + UPDATE projection_step_run + SET status = 'blocked', + waiting_reason = NULL, + error = ${event.payload.reason}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } } }).pipe(Effect.mapError(toProjectionError), Effect.asVoid); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts index 48820c2329c..b7dc03f8a0b 100644 --- a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts @@ -91,6 +91,67 @@ layer("WorkflowReadModel", (it) => { }), ); + it.effect("returns blockedReason for blocked step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-blocked-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "blocked-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Blocked detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "blocked-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-blocked-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-blocked-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "blocked-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-blocked-detail" as never, + stepRunId: "sr-blocked-detail" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepBlocked", + eventId: "blocked-detail-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-blocked-detail" as never, + reason: "Project not trusted to run scripts", + }, + } as never); + + const detail = yield* read.getTicketDetail("t-blocked-detail" as never); + assert.equal(detail?.steps[0]?.status, "blocked"); + assert.equal(detail?.steps[0]?.blockedReason, "Project not trusted to run scripts"); + assert.equal(detail?.steps[0]?.waitingReason, null); + }), + ); + it.effect("lists boards for a project and deletes one", () => Effect.gen(function* () { const read = yield* WorkflowReadModel; diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts index c9ad9cbef50..0cc1a2eebab 100644 --- a/apps/server/src/workflow/Layers/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -116,7 +116,11 @@ const make = Effect.gen(function* () { step_key AS "stepKey", step_type AS "stepType", status, - waiting_reason AS "waitingReason" + waiting_reason AS "waitingReason", + CASE + WHEN status = 'blocked' THEN error + ELSE NULL + END AS "blockedReason" FROM projection_step_run WHERE ticket_id = ${ticketId} ORDER BY started_at ASC diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts index 74e896e8546..ce024c03997 100644 --- a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts @@ -113,4 +113,57 @@ layer("WorkflowRecovery", (it) => { ); }), ); + + it.effect("releases worktree leases for steps that ended blocked", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-step-blocked', + 'ticket-blocked', + 0, + 'StepBlocked', + '2026-06-07T00:00:00.000Z', + '{"stepRunId":"step-run-blocked","reason":"Project not trusted to run scripts"}' + ) + `; + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + 'wt-blocked', + 'step', + 'step-run-blocked', + 7, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:30:00.000Z' + ) + `; + + yield* recovery.recover(); + + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-blocked' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); }); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts index 01e463f7482..99438b247c8 100644 --- a/apps/server/src/workflow/Layers/WorkflowRecovery.ts +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -46,8 +46,10 @@ const wrapSql =
(effect: Effect.Effect) => const isTerminalStepEvent = ( event: PersistedWorkflowEvent, -): event is Extract => - event.type === "StepCompleted" || event.type === "StepFailed"; +): event is Extract< + PersistedWorkflowEvent, + { readonly type: "StepCompleted" | "StepFailed" | "StepBlocked" } +> => event.type === "StepCompleted" || event.type === "StepFailed" || event.type === "StepBlocked"; const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -200,7 +202,7 @@ const make = Effect.gen(function* () { AND EXISTS ( SELECT 1 FROM workflow_events AS events - WHERE events.event_type IN ('StepCompleted', 'StepFailed') + WHERE events.event_type IN ('StepCompleted', 'StepFailed', 'StepBlocked') AND json_extract(events.payload_json, '$.stepRunId') = leases.owner_id ) `); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts index cb79768fe07..a3f09f807b0 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -4,6 +4,7 @@ import { BoardId, LaneKey, type ProjectId, + StepKey, TicketId, WORKFLOW_WS_METHODS, type WorkflowDefinition, @@ -18,9 +19,18 @@ it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => Effect.gen(function* () { const boardId = BoardId.make("board-1"); const backlog = LaneKey.make("backlog"); + const review = LaneKey.make("review"); const definition = { name: "Delivery", - lanes: [{ key: backlog, name: "Backlog", entry: "manual" }], + lanes: [ + { key: backlog, name: "Backlog", entry: "manual" }, + { + key: review, + name: "Review", + entry: "manual", + pipeline: [{ key: StepKey.make("approve"), type: "approval", prompt: "Approve?" }], + }, + ], } satisfies WorkflowDefinition; const handlers = workflowRpcHandlers({ @@ -107,6 +117,8 @@ it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => assert.equal(streamItems[0]?.kind, "snapshot"); if (streamItems[0]?.kind === "snapshot") { assert.equal(streamItems[0].snapshot.board.name, "Delivery"); + assert.equal(streamItems[0].snapshot.board.lanes[0]?.pipelineStepCount, 0); + assert.equal(streamItems[0].snapshot.board.lanes[1]?.pipelineStepCount, 1); assert.equal(streamItems[0].snapshot.tickets[0]?.title, "Existing"); } }), diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts index 54b956938ac..6449001af31 100644 --- a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -100,6 +100,7 @@ const toStepRunView = (step: StepRunRow): WorkflowStepRunView => ({ stepType: step.stepType as "agent" | "approval", status: step.status as StepRunStatus, waitingReason: step.waitingReason, + blockedReason: step.blockedReason, }); const workflowRpcError = (message: string, cause?: unknown) => @@ -143,6 +144,7 @@ const boardSnapshot = ( key: lane.key, name: lane.name, entry: lane.entry, + pipelineStepCount: lane.pipeline?.length ?? 0, ...(lane.terminal === undefined ? {} : { terminal: lane.terminal }), })), }, diff --git a/apps/server/src/workflow/Services/WorkflowEngine.ts b/apps/server/src/workflow/Services/WorkflowEngine.ts index 08c7d8e14bc..06d2ef75235 100644 --- a/apps/server/src/workflow/Services/WorkflowEngine.ts +++ b/apps/server/src/workflow/Services/WorkflowEngine.ts @@ -6,7 +6,8 @@ import type { WorkflowEventStoreError } from "./Errors.ts"; export type RecoveredStepResult = | { readonly _tag: "completed" } - | { readonly _tag: "failed"; readonly error: string }; + | { readonly _tag: "failed"; readonly error: string } + | { readonly _tag: "blocked"; readonly reason: string }; export interface WorkflowEngineShape { readonly createTicket: (input: { diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts index dc131d475e5..8cd2414781f 100644 --- a/apps/server/src/workflow/Services/WorkflowReadModel.ts +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -34,6 +34,7 @@ export interface StepRunRow { readonly stepType: string; readonly status: string; readonly waitingReason: string | null; + readonly blockedReason: string | null; } export interface TicketDetail { diff --git a/apps/web/src/components/board/BoardHeaderControls.browser.tsx b/apps/web/src/components/board/BoardHeaderControls.browser.tsx new file mode 100644 index 00000000000..b032936e2d4 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.browser.tsx @@ -0,0 +1,40 @@ +import "../../index.css"; + +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { BoardHeaderControls } from "./BoardHeaderControls"; + +const lanes = [ + { key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }, + { key: "implement", name: "Implement", entry: "auto", pipelineStepCount: 2 }, +] as const; + +describe("BoardHeaderControls", () => { + it("opens a create-ticket dialog and submits title plus description", async () => { + const onCreateTicket = vi.fn(); + render( + , + ); + + await expect.element(page.getByLabelText("New ticket title")).not.toBeInTheDocument(); + + await page.getByRole("button", { name: "New ticket" }).click(); + await expect.element(page.getByRole("heading", { name: "New ticket" })).toBeInTheDocument(); + + await page.getByLabelText("Ticket title").fill("Ship workflow modal"); + await page + .getByLabelText("Ticket description") + .fill("Acceptance criteria and implementation notes."); + await page.getByRole("button", { name: "Create ticket" }).click(); + + await vi.waitFor(() => { + expect(onCreateTicket).toHaveBeenCalledWith({ + title: "Ship workflow modal", + description: "Acceptance criteria and implementation notes.", + initialLane: "backlog", + }); + }); + }); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.test.tsx b/apps/web/src/components/board/BoardHeaderControls.test.tsx index cba86d3c466..b3e9c4120dd 100644 --- a/apps/web/src/components/board/BoardHeaderControls.test.tsx +++ b/apps/web/src/components/board/BoardHeaderControls.test.tsx @@ -14,14 +14,23 @@ describe("BoardHeaderControls", () => { expect(getDefaultInitialLane([])).toBeNull(); }); - it("renders only new-ticket controls", () => { + it("renders only the closed new-ticket trigger in the board header", () => { const markup = renderToStaticMarkup( {}} />, ); expect(markup).not.toContain("Register board"); expect(markup).toContain("New ticket"); - expect(markup).toContain("Backlog"); - expect(markup).toContain("Implement"); + expect(markup).not.toContain("New ticket title"); + expect(markup).not.toContain("Backlog"); + expect(markup).not.toContain("Implement"); + }); + + it("renders the New ticket action as a dialog trigger button", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + + expect(markup).toMatch(/]*type="button"[^>]*>.*New ticket<\/button>/s); }); }); diff --git a/apps/web/src/components/board/BoardHeaderControls.tsx b/apps/web/src/components/board/BoardHeaderControls.tsx index e3e4301d0bc..b31e78bb255 100644 --- a/apps/web/src/components/board/BoardHeaderControls.tsx +++ b/apps/web/src/components/board/BoardHeaderControls.tsx @@ -2,7 +2,16 @@ import { PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; export interface BoardHeaderLane { readonly key: string; @@ -11,6 +20,7 @@ export interface BoardHeaderLane { export interface NewTicketInput { readonly title: string; + readonly description?: string | undefined; readonly initialLane: string; } @@ -26,7 +36,9 @@ export function BoardHeaderControls({ readonly lanes: ReadonlyArray; readonly onCreateTicket: (input: NewTicketInput) => void; }) { + const [open, setOpen] = useState(false); const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); const [initialLane, setInitialLane] = useState(() => getDefaultInitialLane(lanes) ?? ""); useEffect(() => { @@ -37,47 +49,120 @@ export function BoardHeaderControls({ }, [initialLane, lanes]); const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); const canCreateTicket = Boolean(boardId && initialLane && trimmedTitle); + const resetForm = () => { + setTitle(""); + setDescription(""); + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + }; + return (
- { - event.preventDefault(); - if (!canCreateTicket) { - return; + { + setOpen(nextOpen); + if (!nextOpen) { + resetForm(); } - - onCreateTicket({ title: trimmedTitle, initialLane }); - setTitle(""); }} > - setTitle(event.currentTarget.value)} - aria-label="New ticket title" - /> - - - + +
{ + event.preventDefault(); + if (!canCreateTicket) { + return; + } + + onCreateTicket({ + title: trimmedTitle, + ...(trimmedDescription ? { description: trimmedDescription } : {}), + initialLane, + }); + resetForm(); + setOpen(false); + }} + > + + New ticket + + Capture the work request, context, and acceptance criteria before adding it to the + board. + + +
+ +