diff --git a/.changeset/fix-opencode-go-api-key-env.md b/.changeset/fix-opencode-go-api-key-env.md new file mode 100644 index 0000000000..d43e43e884 --- /dev/null +++ b/.changeset/fix-opencode-go-api-key-env.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": patch +--- + +Fix opencode-go model sync: pass API key to CLI and strip provider prefix from model IDs + +Two bugs when using OpenCode Go as a provider: + +1. **Model discovery only returned free models** — the saved Go API key was never passed as `OPENCODE_API_KEY` to the spawned `opencode models opencode --refresh` process. The CLI's internal plugin checks this env var and, when absent, disables all paid models (those with `cost.input > 0`). Only 20 free models appeared instead of all 67. + +2. **API requests failed with 401** — `normalizeOpencodeGoModel` was registering models with prefixed IDs like `opencode-go/deepseek-v4-flash`. The Pi SDK sends `model.id` verbatim in API requests; the OpenCode API expects bare model names (e.g. `deepseek-v4-flash`). The prefix is now stripped during normalization. + +Also deduplicates models when the CLI emits both `opencode/foo` and `opencode-go/foo` for the same model, guards against empty model IDs, and refactors the duplicated `onApiKeySaved` handler into a shared `handleOpencodeGoApiKeySaved` helper. + +After this change, users must re-select their opencode-go model in Settings because model IDs have changed from prefixed to bare names. diff --git a/.changeset/step-inversion-workflow-modelable-steps.md b/.changeset/step-inversion-workflow-modelable-steps.md new file mode 100644 index 0000000000..177dfb813a --- /dev/null +++ b/.changeset/step-inversion-workflow-modelable-steps.md @@ -0,0 +1,13 @@ +--- +"@runfusion/fusion": minor +--- + +Make task steps workflow-modelable, behind the `experimentalFeatures.workflowGraphExecutor` flag (off by default). + +Step policy — how a task breaks into steps, how each step is reviewed, and what happens on revision/rethink — was previously fixed engine law. Workflows can now model it as graph structure: a `foreach` node instantiates a per-step template subgraph once per planned step; a `step-review` node surfaces APPROVE/REVISE/RETHINK/UNAVAILABLE verdicts as outcome edges; `rework` edges (the only legal graph cycles, bounded per instance) route revisions back to a `step-execute` seam, with RETHINK triggering a substrate reset-to-baseline (git reset + session rewind). Steps additionally gain parallel execution: with `mode: parallel` + per-instance worktrees, dependency-satisfied steps (declared via `### Step N (depends: 1,2):` annotations) run concurrently off a common base, with an ordered integration stage that lands branches in step order and routes rebase conflicts to a budget-counted rework outcome. + +Step parsing itself becomes a graph node: `parse-steps(artifact, parser)` reads a workflow-declared task artifact and runs a registry parser (built-in `step-headings`/`json-steps`, or plugin-contributed parsers under `plugin::`) to write the step list, with routable `no-steps`/`parse-error` outcomes. A `code` node runs sandboxed TypeScript (esbuild + child process, clamped timeout, no store handle) for arbitrary computed routing/field logic. Workflows also declare typed custom task fields (string/text/number/boolean/enum/multi-enum/date/url, with enum options and render hints); values are validated through a single store authority and the task UI renders the field schema dynamically (detail form widgets, card badges, and a workflow-editor Fields panel). `fn_task_update` accepts a `custom_fields` patch; `fn_workflow_create/update` accept the new IR constructs. + +The default coding workflow is untouched and byte-identical (the parity oracle); a new built-in stepwise coding workflow demonstrates the full modeling. With the flag off, step execution, review, and the board are exactly as before. + +**ROLLBACK:** This is flag-gated by `experimentalFeatures.workflowGraphExecutor` and additive on disk. Schema migration v108 only ADDS the `workflow_run_step_instances` table and the `tasks.customFields` column (default `'{}'`) — it rewrites no existing rows. The flag is read once and pinned per run, so a mid-flight toggle never switches a task between the legacy and graph step paths; flag-off rollback mid-task converges via the existing fell-back + git-reconcile recovery, because `Task.steps[]` remains the always-git-reconcilable projection sink. Instance rows are per-run prunable and are never the authority over git history. IR using the new node kinds (`foreach`/`step-review`/`parse-steps`/`code`) is v2-only, and `downgradeIrToV1IfPure` already refuses non-v1 node kinds, so the v2 rollback contract from the columns track is preserved automatically. To downgrade to a pre-v108 binary, turn the flag off and let in-flight stepwise tasks settle (or reconcile from git) first; custom-field values on the dropped column are lost on downgrade, so export any needed field values beforehand. diff --git a/.changeset/workflow-custom-columns-traits.md b/.changeset/workflow-custom-columns-traits.md new file mode 100644 index 0000000000..ace3140c36 --- /dev/null +++ b/.changeset/workflow-custom-columns-traits.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": minor +--- + +Add workflow-defined custom columns with composable traits, behind the `experimentalFeatures.workflowColumns` flag (off by default). + +Workflows can now define their own columns, each carrying composable traits (declarative flags plus lifecycle hooks) instead of the fixed `triage → todo → in-progress → in-review → done → archived` pipeline. The dashboard board renders one lane per workflow in use, and graphs gain `hold`, `split`, and `join` nodes for passive dwell and parallel fan-out/join branches. The built-in default workflow reproduces today's pipeline verbatim, and migration rewrites zero task rows — a null workflow selection resolves to the default workflow at read time. With the flag off, the legacy board, transitions, and engine behavior are unchanged. + +**ROLLBACK:** Workflow IR now has a `v2` on-disk shape (custom columns + `hold`/`split`/`join` nodes). Pre-v2 binaries hard-reject any IR whose `version !== 'v1'`, so a naive downgrade would brick rows that had been re-serialized as v2. To keep rollback safe, the store downgrades a workflow back to the `v1` shape on save whenever (a) the `experimentalFeatures.workflowColumns` flag is OFF, and (b) the graph is "pure v1" — only `start`/`prompt`/`script`/`gate`/`end` nodes, no `hold`/`split`/`join`, and exactly the synthesized default columns at their default seam-derived placement. v2 is persisted only when the flag is ON or a genuine v2 feature (custom column, applied trait, custom placement, or a v2-only node) is in use. Reading a downgraded `v1` row on a v2 binary re-upgrades it to the identical v2 graph, so this is lossless. Rollback is therefore only unsafe for workflows that actually use v2 features with the flag ON; turn the flag OFF and re-save such workflows (or delete them) before downgrading to a pre-v2 binary. diff --git a/.changeset/workflow-graph-editor-and-bundled-plugins.md b/.changeset/workflow-graph-editor-and-bundled-plugins.md index 46e4effd53..d8b6bb60fc 100644 --- a/.changeset/workflow-graph-editor-and-bundled-plugins.md +++ b/.changeset/workflow-graph-editor-and-bundled-plugins.md @@ -6,3 +6,5 @@ Fix the workflow graph editor opening invisibly and bundle the Compound Engineer - The "Graph editor" button now actually shows the editor: its overlay was rendered without the `open` class, leaving it `display: none`, so opening it looked like the workflow steps view was just dismissed. - `fusion-plugin-compound-engineering` and `fusion-plugin-roadmap` are now listed in the dashboard's built-in plugins, so they appear under Settings → Built-in Plugins (they were implemented and registered but missing from the list). +- Installing Compound Engineering (and CLI Printing Press) from Settings → Built-in Plugins no longer fails with "Plugin manifest not found": both ids are now in the dashboard's bundled-plugin fallback set, and the Compound Engineering plugin is staged into `dist/plugins/` so packaged installs can resolve it. +- Plugins installed from Settings now load instead of erroring with "Plugin entry must be a file, got directory": the dashboard install routes register the plugin's loadable entry file (`bundled.js`/`dist/index.js`/`src/index.ts`) rather than the package directory, and enabling a plugin heals legacy directory-path registrations in place. diff --git a/CONCEPTS.md b/CONCEPTS.md index 05a6427e91..1761ed67d8 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -134,6 +134,55 @@ The user's mid-stage feedback channel: free-text guidance attached to an answer, ### Rehydration Re-establishing a live agent handle for a paused CE Session by replaying its recorded conversation against the model. Replay is side-effect-suppressed: it reconstructs the agent's context without re-emitting events, re-streaming Live activity, or re-writing artifacts. +## Plugins + +### Bundled Plugin +A plugin that ships inside the Fusion distribution itself rather than being installed from a user-supplied path — it appears under Settings → Built-in Plugins and can be auto-installed at startup. +*Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing) + +A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface. + +### Plugin Entry +The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed. + +## Workflow columns & traits + +*Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.* + +### Column (workflow-defined) +A first-class, workflow-defined unit of task state: an id, a display name, and a set of Trait configurations. A Task's board position is its current column, persisted in `tasks."column"`. Column validity is workflow-scoped — the legacy closed enum widens to a string validated against the Task's resolved workflow. The Default workflow's column ids are byte-identical to the legacy enum values, so no task row is ever rewritten. + +### Trait +Composable column configuration: declarative flags (e.g. `complete`, `archived`, `countsTowardWip`) plus optional lifecycle hooks (`guard`, `gate`, `onEnter`, `onExit`, `releaseCondition`). Built-in and plugin-contributed traits register through one registry. Sync `guard` hooks and the `complete`/`archived` flags are built-in-only; plugin traits get async hook points only. A column's effective flags are the merged flags of its traits; conflicting compositions are rejected at save (server-side and in the editor). + +### Lane +A horizontal row on the multi-lane board, one per workflow in use by visible cards. Each lane renders its own workflow's columns. Tasks with no workflow selection appear in the Default workflow's lane; every card appears in exactly one lane. Zero-card lanes are hidden; lanes are collapsible with persisted state. + +### Hold node +A workflow node kind expressing passive dwell — a card rests in its column until a release condition fires: manual promote, timer, downstream capacity available, dependency satisfied, or external event. Hold release is evaluated by a substrate sweep (the generalized scheduler), which reserves worktree + semaphore slots before issuing the release move. + +### Split / Join +Parallel-branch node kinds. A `split` launches its outgoing edges concurrently; a `join` synchronizes them with `mode: all | any | quorum(n)` and `onBranchFailure: fail-fast | collect`. During the parallel window the card stays in the split's column (its board position never forks); on join resolution it advances to the join's column. `execute`/`merge` seam nodes are forbidden inside branches (one worktree/session per task; merge is exclusive). Per-branch run state persists in SQLite so a crashed branch resumes where it died. + +### Default workflow +The built-in workflow (`builtin:coding`) that reproduces the legacy pipeline verbatim: six columns whose ids equal the legacy enum values, with traits matching legacy semantics (`triage`=intake, `todo`=hold+reset-on-entry, `in-progress`=wip+abort-on-exit+timing, `in-review`=merge-blocker+stall-detection+merge, `done`=complete, `archived`=archived). A null workflow selection resolves to it at read time. Non-editable, non-deletable. + +### transitionPending +A persisted crash-safe marker (`tasks.transitionPending`) written in the same transaction as a column change, recording the post-commit hooks (`hooksRemaining`) that still owe idempotent execution. Cleared once they complete. Recovery reads it exclusively from SQLite (the authoritative store); a crash mid-transition re-runs the idempotent hooks. A throwing or missing hook degrades (audit) and clears its entry — it never strands the card or wedges the task lock. + +## Step inversion + +*Behind the `experimentalFeatures.workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off, and for the Default workflow always, step policy is the legacy engine-owned path (PROMPT.md parsing, in-session review verdicts, RETHINK reset) — unchanged.* + +### Step instance +One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in `workflow_run_step_instances` (schema v108). The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. + +### parse-steps +A workflow graph node that reads a declared Artifact and runs a registry parser to write the canonical step list (`Task.steps[]`) — the only graph-side writer of steps. Built-in parsers are `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex, including the `(depends: N,M)` annotation) and `json-steps`; plugins contribute parsers under `plugin::`. Parsing failures fail closed to a routable `outcome:parse-error` rather than crashing. A parse-steps node must dominate (precede on all paths) any `foreach(source:"task-steps")`, and running one after a foreach has already expanded trips pin protection (an audited failure) so re-plan loops cannot desynchronize an expanded region. + +### Custom task field +A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. + ## Testing ### Merge Gate diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 288ee955b5..2c25c2b751 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -20,6 +20,7 @@ A comprehensive guide to creating Fusion plugins that extend the task board with 14. [Example Plugins](#14-example-plugins) 15. [Registering Skills](#15-registering-skills) 16. [Registering Workflow Steps](#16-registering-workflow-steps) +16.5. [Contributing Column Traits](#165-contributing-column-traits) 17. [Contributing Prompt Modifications](#17-contributing-prompt-modifications) 18. [Plugin Binary Setup Hooks](#18-plugin-binary-setup-hooks) @@ -1476,6 +1477,134 @@ Use `mode: "prompt" | "script"` and `toolMode: "readonly" | "coding"`. Plugin-contributed workflow steps are materialized through core `resolvePluginWorkflowStep(...)`; `mode`, `phase`, `scriptName`, `toolMode`, `defaultOn`, `modelProvider`, and `modelId` are preserved from your contribution (with defaults when omitted). +## 16.5. Contributing Column Traits + +> Requires the `experimentalFeatures.workflowColumns` flag. Traits are the +> composable building blocks of workflow-defined columns (declarative flags + +> lifecycle hooks). Plugins contribute traits the same way they contribute +> workflow steps: declare them on the plugin object and the engine aggregates, +> caches, and invalidates them through the `PluginRunner` (mirroring +> `workflowSteps`). + +A plugin trait is registered into the core trait registry under a +plugin-namespaced id `plugin::`, so it can never collide +with a built-in trait or another plugin's trait, and it resolves through the +same registry lookup as the 14 built-in traits. + +```typescript +import type { PluginTraitContribution } from "@fusion/plugin-sdk"; + +const traits: PluginTraitContribution[] = [ + { + traitId: "security-approval", + name: "Security Approval Gate", + description: "Holds a card until a security review prompt passes.", + schemaVersion: 1, + flags: { gate: true }, + hooks: { + gate: { + mode: "prompt", + prompt: "Approve this change for security-sensitive paths?", + gateMode: "blocking", + }, + }, + }, + { + traitId: "slack-notify", + name: "Slack Notify", + description: "Posts to Slack when a card enters/leaves the column.", + schemaVersion: 1, + flags: { notify: true }, + hooks: { + onEnter: { mode: "script", scriptName: "slack-notify-enter" }, + onExit: { mode: "script", scriptName: "slack-notify-exit" }, + }, + }, +]; + +export default definePlugin({ + manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" }, + hooks: {}, + traits, +}); +``` + +### Contribution shape + +| Field | Required | Notes | +|---|---|---| +| `traitId` | yes | kebab-case slug, unique within the plugin | +| `name` | yes | display name | +| `description` | no | UI description | +| `schemaVersion` | yes | must be `1` — the versioned hook-descriptor contract (see below) | +| `flags` | no | declarative flags (restricted flags rejected, see below) | +| `configSchema` | no | declarative config fields (`{ fields: [...] }`) | +| `hooks` | no | async hook descriptors (see below) | + +### Hook points (async only) + +Plugin traits get **async hook points only**: + +- `gate` — evaluated **before** a card moves into the column (pre-move, outside + the task lock). The verdict is recorded and re-checked cheaply when the move + commits. +- `onEnter` / `onExit` — post-commit, async, idempotent effects. +- `releaseCondition` — evaluated by the hold/release sweep for `hold` columns. + +The synchronous `guard` hook point is **built-in-only** and is rejected at +validation. Sync guards run inside the task lock and must be fast and pure — a +plugin hook there could wedge the lock, so plugins use the async `gate` surface +instead. + +Each hook descriptor mirrors the workflow-step shape: + +```typescript +{ mode: "prompt" | "script", prompt?: string, scriptName?: string, gateMode?: "blocking" | "advisory" } +``` + +Hooks execute through the **same prompt-session / script / verdict machinery** +contributed workflow steps use — plugin trait code never runs raw in-process. + +### Gate semantics + +- `gateMode: "blocking"` (default for gates) **fails closed**: a non-pass + verdict — or no recorded verdict at move time — rejects the move with a typed + `TransitionRejection`. +- `gateMode: "advisory"` **records and allows**: the verdict is logged but the + move proceeds. +- Engine-sourced and recovery moves bypass gates entirely (they carry + `bypassGuards`), so self-healing is never blocked by a plugin gate. + +### Restricted flags + +A plugin trait may **not** declare these flags (rejected at validation, and as a +backstop at registry registration): + +- `complete` — a terminal-success column that silently satisfies dependencies. +- `archived` — globally hidden column semantics. + +A plugin needing those semantics composes its trait **alongside** the built-in +`complete` / `archived` trait on the same column. + +### Versioned hook-descriptor schema + +`schemaVersion: 1` is required. It pins the hook-descriptor contract so the +built-in trait vocabulary can grow additively (new flags, hook points, config +fields) without breaking already-published plugin traits. Validate your +contribution with `validatePluginTraitContribution(...)` from +`@fusion/plugin-sdk`. + +### Disabling a plugin with live dependents + +If a card is currently sitting in a column that uses one of your plugin's +traits, disabling/uninstalling the plugin is **blocked** with a typed error +listing the dependent tasks (mirroring the built-in-workflow deletion block). + +A **force** path degrades the affected columns to **passive**: the trait's hooks +become no-ops (the registry resolves them to a no-op plus an audit warning), a +single audit event is emitted, and the cards remain fully movable. A degraded +gate column never blocks a card. + ## 17. Contributing Prompt Modifications Prompt contributions let a plugin inject additional instructions into specific prompt surfaces. diff --git a/docs/architecture.md b/docs/architecture.md index 98469f2b47..7d7e8d8949 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1230,6 +1230,42 @@ Detection is visibility-only: no scheduler/self-healing actions are triggered by Tune sensitivity by adjusting the exported constants in `stalled-review-detector.ts`. Increase thresholds to reduce noise; decrease thresholds only with incident evidence, because lower values can over-flag transient recovery bursts. +--- + +### Workflow-defined columns & traits (`experimentalFeatures.workflowColumns`) + +*Behind the `workflowColumns` flag (accessor: `packages/core/src/workflow-columns-settings.ts`). With the flag off the legacy pipeline above is authoritative and untouched. The flag default-flips only when the graduation report (below) shows zero drift — a field decision, not yet taken.* + +**Engine as substrate, workflows as policy.** The flag inverts the architecture: the engine becomes a **capability substrate** (worktree/git/session mechanics, persistence, crash recovery, audit, machine resource ceilings — non-configurable) and **workflows carry the operating logic** as composable column traits. The mechanism/policy line (KTD-4): + +- **Substrate (engine-owned, never workflow-configurable):** `AgentSemaphore`, checkout leases, worktree/git/session ops, SQLite + WAL, the crash-recovery machinery, the audit trail, the global max-sessions cap, and the three non-configurable lost-work merge guards (no sibling `fusion/fn-*` target, line-anchored attribution, no `modifiedFiles` clear on a no-op finalize). +- **Policy (workflow/trait-owned):** transition validity, WIP/capacity, hold/release, drag meaning, retries, merge strategy, squash posture, file-scope enforcement mode. + +**Transition authority.** `moveTaskInternal` remains the single transition authority. Flag-on, it swaps the `VALID_TRANSITIONS` lookup for workflow-resolved column-graph validation (`resolveAllowedColumns`/`workflowHasColumn` in `workflow-transitions.ts`) plus sync trait guards run in-lock; rejections are typed `TransitionRejection`s. `VALID_TRANSITIONS` and the closed `Column`/`COLUMNS` helpers in `types.ts` are `@deprecated` while the flag exists — retained as the flag-off authority and the parity oracle, not yet removed. + +**Trait model.** A trait is declarative flags + optional lifecycle hooks (`guard`, `gate`, `onEnter`, `onExit`, `releaseCondition`), resolved through one registry (`trait-registry.ts`, built-ins in `builtin-traits.ts`). Sync `guard` and the `complete`/`archived` flags are built-in-only; plugin traits (KTD-7) get async hook points only and route through the prompt-session/script machinery. Composition conflicts are rejected at save both in the editor and server-side (`assertColumnTraitsValid` in `createWorkflowDefinition`/`updateWorkflowDefinition`, surfaced as a 400). Capacity is enforced in-txn (KTD-10), never bypassable — not a guard. Enter/exit effects run post-commit, idempotent, guarded by the `transitionPending` marker; a throwing/missing plugin hook degrades (audit) and never strands the card or wedges the lock. + +**Graduation.** The flag default-flip is gated by `computeWorkflowColumnsGraduationReport()` (`workflow-parity.ts`; store method `TaskStore.computeWorkflowColumnsGraduationReport`), aggregating: five-invariant dual-observe parity, default-workflow transition parity vs `VALID_TRANSITIONS` (`checkTransitionParity`), and the U6 dual-accept marker/column disagreement count. `ready` is true only when all gates pass over a non-empty observation window. The report is the gate; it does not flip the flag. + +### Step inversion: steps as workflow-modelable nodes (`experimentalFeatures.workflowGraphExecutor`) + +The columns/traits track moved *board* policy (transitions, capacity, hold, merge orchestration) onto the substrate/policy line. The **step-inversion** track extends the same inversion to *task steps* and to the *task shape itself*, riding the existing `workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off — and for the default coding workflow always — step policy stays exactly as it is today (the monolithic `execute` seam, PROMPT.md `### Step N:` parsing, in-session `fn_review_step` verdicts, RETHINK git-reset/session-rewind). The default workflow is the byte-identical parity oracle; inversion is opt-in via custom workflows and a built-in stepwise coding workflow. + +**One new substrate seam pair.** The substrate gains exactly one new capability, expressed as two methods: `runTaskStep(task, stepIndex)` (run exactly one step inside the task's session and observe its `complete Step N` commit) and `resetStepToBaseline(task, stepIndex, baselineSha, checkpointId?)` (the RETHINK mechanics — git reset + session rewind + `updateStep(...,"pending")`). Both delegate to existing code (extracted from `StepSessionExecutor` and the legacy RETHINK block); neither reimplements step physics or authors commits. The substrate owns *how* a step runs and resets; the graph owns *when*. Baseline/checkpoint state, previously fragile in-memory Maps lost on restart, moves into persisted instance run-state (`workflow_run_step_instances`, schema v108). + +**Everything else becomes authored graph structure (policy).** Step granularity, per-step plan/code review, the verdict→action mapping, rework/escalation routing, parallelism, and even the existence of PROMPT.md stop being engine law: + +- A `parse-steps` node reads a workflow-declared **artifact** (PROMPT.md is just the default workflow's declared `step-source` artifact) and runs a registry **parser** (`step-headings`, `json-steps`, or a plugin-contributed parser) to write `Task.steps[]`. It is the only graph-side step-list writer and must dominate any `foreach`. Parsers fail closed to a routable `outcome:parse-error`. +- A `foreach(source:"task-steps")` node instantiates an inline template subgraph once per planned step, with `mode` (sequential/parallel) and `isolation` (shared/worktree) as explicit axes and per-instance run-state pinned + persisted for crash-safe resume. +- A `step-review` node surfaces reviewer verdicts (APPROVE/REVISE/RETHINK/UNAVAILABLE) as outcome edges; `rework` edges (the only legal graph cycles, bounded per instance) route REVISE/RETHINK back to `step-execute`, with RETHINK traversal triggering the reset seam. +- A `code` node runs sandboxed TypeScript (esbuild + child process, clamped timeout, no store handle) for arbitrary computed routing/field logic — the same trust tier as project-local script steps. + +**`Task.steps[]` stays the physical projection sink.** Instance lifecycle transitions write *through* `store.updateStep` with explicit indices (projection-first ordering closes the merge-blocker race), so every existing consumer — the merge-blocker, dashboard/TUI step display, `reconcileStepsFromGitHistory`, lost-work reset — keeps working unchanged. Git reconcile remains authoritative over the instance rows (rows are corrected to match git, never the reverse). + +**Task shape recast.** The task model reduces to core fields (title, description) + standard metadata + **workflow-defined custom fields** (typed, enum options, render hints; values in `tasks.customFields`, validated through one store authority with typed rejections). Field-schema edits orphan rather than destroy values. This round ships the field *system*; recasting existing built-in fields (priority, labels) onto it is a deferred, additive follow-up. + +**Invariant bar.** The five lifecycle invariants (FN-5147 terminal-until-merged, hard-cancel, in-review stall, file-scope, squash) plus the lost-work guard trio remain the non-configurable correctness bar on the stepwise path. The v108 migration is additive; instance rows are prunable; flag-off rollback mid-task converges via the existing fell-back + git-reconcile recovery (the projection is always git-reconcilable). + ## 10) Agent System Fusion has two complementary agent models: diff --git a/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md b/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md index 8e0db0e2b0..93cd5b76ff 100644 --- a/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md +++ b/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md @@ -1,7 +1,7 @@ --- title: "feat: Workflow interpreter cutover — graph owns the full lifecycle" type: feat -status: active +status: superseded date: 2026-06-03 depth: deep origin: docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md (Deferred Track) diff --git a/docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md b/docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md new file mode 100644 index 0000000000..1b93c91c66 --- /dev/null +++ b/docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md @@ -0,0 +1,499 @@ +--- +title: "feat: Workflow-defined custom columns — engine as substrate, workflows as operating logic" +type: feat +status: completed +date: 2026-06-03 +depth: deep +origin: none (solo planning bootstrap) +supersedes: docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md +--- + +# feat: Workflow-defined custom columns — engine as substrate, workflows as operating logic + +## Summary + +Invert the architecture: the engine becomes a **capability substrate** (worktree/git/session mechanics, persistence, crash recovery, audit, resource ceilings) and **workflows become the operating logic**. Columns become first-class, workflow-defined task state carrying **composable, pluggable traits** (declarative flags + executable lifecycle hooks). All policy — state transitions, retries, WIP/capacity, hold/dwell states, drag semantics, merge/PR orchestration, merge strategy, squash contract, file-scope guards — moves into workflows as trait configuration over substrate capabilities. The dashboard board becomes multi-lane (one lane per workflow in use, each rendering its workflow's columns). Today's fixed `triage → todo → in-progress → in-review → done → archived` pipeline is recast as a built-in **default workflow** whose trait configuration reproduces current behavior verbatim, with a flag-gated, additive-only migration. Workflow graphs additionally gain **parallel fan-out/join branches** (`split`/`join` nodes), and the built-in trait vocabulary is fully defined in this plan (see Trait Vocabulary). + +--- + +## Problem Frame + +The executable-custom-workflows MVP (origin: `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md`, completed) shipped persisted `WorkflowIr` definitions, a React Flow node editor, and per-task workflow selection. The interpreter-cutover track (plan 002, M-A–M-C implemented behind the `workflowGraphExecutor` flag) lets a graph drive execute → review → merge sequencing through seams. But both stop at the same ceiling: **the board model is still the engine's**. Columns are a closed enum (`COLUMNS`, `packages/core/src/types.ts:18`), the transition graph is a hardcoded constant (`VALID_TRANSITIONS`), scheduling is a hardcoded "pull from todo, run N agents" loop, and every reliability invariant is keyed to literal column names across ~158 files. + +Consequences: + +- A workflow cannot express its own lifecycle — e.g., a passive "Planning" holding column, planning processing in "Todo", then capacity-limited pickup into execution. The graph can only decorate the fixed pipeline. +- Custom workflows have no board presence: every task renders into the same six columns regardless of what its workflow actually does. +- Policy and mechanism are fused: `store.moveTask` (`packages/core/src/store.ts`, `moveTaskInternal`) hardcodes transition validity, merge-blocker gating, timing accounting, and reopen semantics in one branch-per-column-name function; the scheduler hardcodes WIP policy; the merger hardcodes merge policy alongside merge mechanics. + +The user has explicitly waived the FN-4359 reliability freeze for this track (carried over from plan 002's waiver). The five lifecycle invariants remain the correctness bar — but as **trait configuration of the default workflow**, not as hardcoded law. + +--- + +## Scope Boundaries + +### In scope + +- Workflow-defined columns with composable traits (flags + lifecycle hooks); trait registry with built-in and plugin-contributed traits sharing one interface; the full built-in trait vocabulary (see Trait Vocabulary). +- In-graph parallelism: `split`/`join` node kinds with `all | any | quorum(n)` join modes, fail-fast or collect failure policies, and per-branch crash-recoverable run state (U13). +- Policy inversion: transitions, retries, WIP/capacity, hold/release, drag semantics, merge/PR orchestration, merge strategy, squash contract, file-scope guard configuration — all workflow-expressed. +- Substrate hardening: the engine keeps mechanisms (worktree/git/session ops, `AgentSemaphore`/leases, persistence, crash recovery, audit, machine resource ceilings) exposed as capabilities traits bind. +- Built-in default workflow reproducing today's pipeline and invariants verbatim; flag-gated cutover; one-time additive migration. +- Multi-lane dashboard board; node-editor support for columns, traits, and hold nodes. +- Graceful degradation for CLI TUI and mobile surfaces that don't yet understand custom columns. + +### Deferred to Follow-Up Work + +- **Removing the legacy engine pipeline code** — only after graduation (U12) proves parity in the field; tracked as the absorbed FN-5719 Phase 4 follow-up. +- Workflow/trait versioning and history, import/export, cross-project workflow sharing. +- Mobile/desktop native surfaces — the mobile app embeds the dashboard web UI and inherits the multi-lane board from U9; `packages/mobile/` contains only native-shell bridging plugins (no task-list views), so there is no mobile degradation work in this plan. Any future native task-list surface is follow-up work. +- Concurrent execution of `execute`/`merge` seam nodes within parallel branches — one worktree/session per task and exclusive merge are physical constraints; the U1 validator rejects such graphs. + +### Outside this product's identity + +- A general-purpose BPM engine: traits exist to run *coding-agent task lifecycles*, not arbitrary business processes. No human-task assignment systems, SLAs, or cross-system orchestration. +- Runtime plugin code isolation/sandboxing beyond the existing install-time trust model — plugin trait hooks route through the existing prompt-session/script machinery (KTD-7); a vm-level sandbox is a separate product decision. + +--- + +## Key Technical Decisions + +- **KTD-1 — Default workflow column IDs are the legacy enum values.** The default workflow's columns are `triage`, `todo`, `in-progress`, `in-review`, `done`, `archived` — byte-identical to today's `tasks."column"` values. Migration therefore rewrites **zero task rows**: a task with no workflow selection resolves to the default workflow, and its stored column value is already a valid column ID in it. `Column` widens from a closed union to `string` validated against the task's workflow definition. + +- **KTD-2 — A trait is declarative flags + optional executable lifecycle hooks, with two guard classes.** Hook points: `guard(move)`, `gate(move)`, `onEnter(task)`, `onExit(task)`, `releaseCondition(task)`. **Sync guards** run inside `withTaskLock`/`moveTaskInternal` and must be fast and pure (DB reads only) — **built-in traits only**. **Async gates** (script/prompt verdicts) are evaluated *before* the move is attempted, outside the lock; the verdict is recorded and re-checked cheaply (a DB read) in-lock at move time — this is the plugin-facing gate surface, and it removes any path where plugin code can block or wedge the task lock. Enter/exit effects run **post-commit, async, idempotent**, tracked by a persisted `transitionPending` marker so crash-mid-transition is recoverable by re-running the idempotent hook (see KTD-9); `transitionPending` recovery reads **exclusively from SQLite** (the authoritative store per ADR-0001 — `task.json` is a follower written post-commit and may be stale across a crash). A hook failure degrades that column's behavior with an audit event — it never strands the card or wedges the task lock. + +- **KTD-3 — `moveTaskInternal` remains the single transition authority.** It swaps its `VALID_TRANSITIONS` lookup for workflow-resolved column-graph validation plus trait guards, but stays the only sanctioned path — all 40+ self-healing call sites, the scheduler, drag handlers, and seams keep calling it. Guard rejections become a **typed `TransitionRejection`** (reason code, user-facing message key, retryable flag) replacing today's thrown strings, so dashboard drops, CLI, and recovery share one rejection contract. + +- **KTD-4 — The mechanism/policy line.** Substrate (engine-owned, not workflow-configurable): `AgentSemaphore`, checkout leases (409 = hard conflict), worktree/git/session operations, SQLite persistence + WAL, crash-recovery machinery, audit trail, machine resource ceilings (a global max-sessions cap survives as physical governance). Policy (workflow/trait-owned): everything currently keyed on a column literal — transition validity, WIP counts and where they apply, retries, hold/release, drag meaning, merge strategy, squash posture, file-scope enforcement mode. This is DAG ADR-0001's enqueue-only posture generalized: traits *configure and invoke* capabilities; they never reimplement them. + +- **KTD-5 — No reserved column names; completion and archival are trait flags.** `complete: true` and `archived: true` trait flags replace string equality on `done`/`archived` everywhere: dependency satisfaction (`scheduler` gating becomes "dependency's task is in a `complete`-flagged column"), archive log, board filters, `clearDoneTransientFields`. Per FN-5719, scheduler dependency checks dual-accept (explicit handoff marker OR complete-flag column) with audit-diff logging during the transition window. + +- **KTD-6 — Merge is a trait that enqueues; it never awaits inside a graph walk.** The merge trait binds the persisted merge-request queue (`enqueueMergeQueue` / `pickNextMergeTaskId` / `ProjectEngine.onMerge` resolver) and configures policy: merge strategy (squash / merge-commit / rebase / PR-only), squash-contract posture, file-scope enforcement (`strict` / `warn` / `off` / custom scope rules), conflict strategy. The substrate merge capability keeps its mechanics (staging allowlist, deterministic subject, audit). The three 2026-05-23 lost-work guards are **capability-level and non-configurable**: never resolve a merge target to a sibling `fusion/fn-*` branch, line-anchored commit attribution only, never clear `modifiedFiles` on a no-op finalize that claimed work. **Identity note — the deliberate power-over-safety-floor posture:** integrity guarantees (the lost-work trio, git-state safety, crash recovery, audit) are non-configurable; *enforcement-mode* protections (`fileScope`, squash posture, review gates) are user-lowerable, but only inside an explicitly authored workflow — never as ambient settings — and the editor surfaces lowered floors visibly. A workflow *can* author itself footguns; it cannot author data loss. + +- **KTD-7 — Plugin traits follow the PluginRunner contribution pattern.** Plugins declare traits in their manifest (flags + hook descriptors), aggregated/cached/invalidated by `PluginRunner` exactly like `PluginWorkflowStepContribution`. Executable hooks route through the existing prompt-session/script/verdict machinery (readonly-tool-policy aware) — traits never get raw in-process execution. Plugin traits get **async hook points only** (`gate`, `onEnter`, `onExit`, `releaseCondition`); the sync `guard` hook point is built-in-only (KTD-2). **Restricted flags**: `complete`, `archived`, and sync-guard capability cannot be declared by plugin traits — a plugin trait declaring `complete: true` could silently satisfy dependencies and poison scheduling; plugins needing those semantics compose alongside the built-in traits on the same column. Disabling/uninstalling a plugin whose trait has live dependents (cards currently in columns using it) is blocked with a clear error, mirroring the built-in-workflow deletion block (`store.ts` `isBuiltinWorkflowId` guard); a force path degrades affected columns to passive (hooks become no-ops, audit event emitted, cards remain movable). + +- **KTD-8 — Flag-gated cutover; legacy path retained until graduation.** A new `experimentalFeatures.workflowColumns` flag gates the whole model. Off: the legacy enum/`VALID_TRANSITIONS` path runs untouched. On: workflow-resolved columns drive everything. The default workflow's parity is machine-checked (extending `compareWorkflowRunObservations` / `workflow-parity.ts` and a dedicated transition-parity suite) before the flag defaults on. This plan **supersedes plan 002**: its M-A–M-C implementations (seams, handlers, runner, executor wiring) are this plan's foundation; its M-D graduation criteria are absorbed into U12. + +- **KTD-9 — Single recovery authority; engine-sourced moves bypass trait hooks.** Self-healing keeps exclusive ownership of recovery transitions (FN-5335 triple-proof, FN-5704 non-oscillation). Moves with `moveSource: "engine"` bypass trait guards and `abort-on-exit` effects (the generalization of today's `skipMergeBlocker`), carrying an explicit `bypassGuards` field in the move options so the bypass is visible in audit. A workflow and self-healing never both claim transition authority over the same task: trait hooks observe engine moves (for bookkeeping like WIP counters, which must stay consistent) but cannot veto or side-effect them. + +- **KTD-10 — Capacity is enforced under the store transaction, not by trait code, and is never bypassable.** WIP limits are trait *configuration*; enforcement is a substrate capability: a per-(workflow, column) active-count check inside `moveTaskInternal`'s transaction plus the existing `AgentSemaphore` for session slots. The in-txn capacity check is **not a guard** — it runs regardless of `bypassGuards`, so engine/recovery/sweep moves honor it too. Hold release is a substrate sweep (the generalized scheduler) that evaluates `releaseCondition`s and calls `moveTask` with a distinct `moveSource: "scheduler"` — releases serialize through the same transaction-time check, so two holds can't release into one slot. Ordering contract with out-of-txn resources: the sweep **reserves worktree + semaphore slots before issuing the move** and releases the reservation if the move's capacity check rejects — a card is never moved into a processing column it cannot actually start in. The scheduler's three-gate diagnostic (`computeConcurrencyGateDiagnostic`) is preserved, generalized to report per-column capacity gates. + +- **KTD-11 — Fan-out runs branches concurrently; the card's board position does not fork.** `split` launches all outgoing edges concurrently; `join` synchronizes with `mode: all | any | quorum(n)` and `onBranchFailure: fail-fast` (cancel siblings via the abort machinery) or `collect` (wait for all, evaluate at join). During parallel execution the card **stays in the split node's column** with per-branch progress surfaced on the card; on join resolution it proceeds to the join's column — one-card-one-position (R16) holds by construction. `execute` and `merge` seam nodes are **forbidden inside branches** (one worktree/session per task; merge is exclusive — physics, not policy); prompt/script/gate nodes are allowed and stay bounded by `AgentSemaphore` + node capacity. Per-branch run state persists in SQLite so a crashed branch resumes where it died (ADR-0001). + +--- + +## Trait Vocabulary + +A trait is declarative flags + config + optional hooks (KTD-2's two guard classes: sync guards built-in-only; async gates are the plugin surface). The built-in set ships in U2: + +| Trait | Category | Config | Hooks | Notes | +|---|---|---|---|---| +| `intake` | identity | `autoTriage?` | — | Where new cards land; exactly one per workflow (validated) | +| `complete` | identity | — | — | Terminal success; satisfies dependencies. **Restricted flag** | +| `archived` | identity | — | — | Hidden from board; global semantics. **Restricted flag** | +| `merge-blocker` | gate | — | sync guard | Generalized FN-5147: entry to `complete`-bound columns blocked until the merge-class node completed (reads `getTaskMergeBlocker`) | +| `wip` | flow | `limit`, `countPending?` | — | Substrate-enforced in-txn (KTD-10); never bypassable | +| `hold` | flow | `release: manual \| timer \| capacity \| dependency \| external-event` | releaseCondition | Passive dwell; `external-event` = webhook/API release | +| `human-review` | gate | `approvers?`, `checklist?` | sync guard (exit) | Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). **Not on the default workflow** — legacy in-review has no human gate; adding one would break R12 parity | +| `gate` | gate | `gateMode: blocking \| advisory`, prompt/script | async gate | Workflow-step gate semantics generalized to columns; the plugin-facing gate surface; blocking gates fail closed | +| `merge` | capability | `strategy`, `fileScope`, `squash`, `conflictStrategy` | onEnter (enqueues), onExit (dequeues) | KTD-6; `onExit` absorbs `dequeueMergeQueueOnColumnExit` | +| `abort-on-exit` | lifecycle | `direction: backward \| any`, `confirm?` | onExit | Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9) | +| `reset-on-entry` | lifecycle | `preserveProgress?` | onEnter | Legacy reopen-to-todo field/step resets | +| `timing` | lifecycle | — | onEnter/onExit | `cumulativeActiveMs` accounting generalized | +| `stall-detection` | observability | `timeoutMs`, `action: annotate \| notify \| move` | (sweep-evaluated) | In-review stall signals generalized to any column | +| `notify` | observability | events, channel | onEnter/onExit | Basic notifications; richer notification traits are the canonical plugin example | + +**Default workflow mapping** (reproduces legacy behavior verbatim, R12): `triage` = `intake`; `todo` = `hold(capacity)` + `reset-on-entry`; `in-progress` = `wip(maxConcurrent)` + `abort-on-exit` + `timing`; `in-review` = `merge-blocker` + `stall-detection` + `merge`; `done` = `complete`; `archived` = `archived`. + +**Extension contract:** the plugin-facing `PluginTraitContribution` carries a **versioned hook-descriptor schema** so the vocabulary can grow additively (new flags, hook points, config fields) without breaking published plugin traits. + +--- + +## High-Level Technical Design + +### Component topology — substrate, traits, workflows + +```mermaid +flowchart TB + subgraph Workflows["Workflow layer (policy)"] + WD["WorkflowDefinition v2
columns + traits + nodes"] + DW["Built-in default workflow
(reproduces legacy pipeline)"] + PT["Plugin-contributed traits"] + end + + subgraph Registry["Trait registry"] + TR["TraitRegistry
built-in + plugin traits
one interface: flags + hooks"] + end + + subgraph Authority["Transition authority (core)"] + MT["moveTaskInternal
workflow-resolved validation
trait guards (sync, in-lock)
transitionPending marker"] + end + + subgraph Substrate["Engine substrate (mechanism)"] + CAP["Capabilities:
execute (sessions/worktrees)
merge (queue, squash, scope)
review, schedule"] + HOLD["Hold/capacity sweep
(generalized scheduler)"] + SH["Self-healing / recovery
(single recovery authority,
bypasses trait hooks)"] + DB[("SQLite
tasks, workflows,
task_workflow_selection")] + end + + WD --> TR + DW --> TR + PT --> TR + TR --> MT + WD -->|"node placement,
capacity config"| HOLD + HOLD -->|moveTask| MT + SH -->|"moveTask
(bypassGuards)"| MT + MT --> DB + TR -->|"hooks invoke
capabilities only"| CAP + CAP --> DB +``` + +### Transition sequence — guard in-lock, effects post-commit + +```mermaid +sequenceDiagram + participant Caller as Caller (drag / sweep / seam / recovery) + participant Store as moveTaskInternal (withTaskLock) + participant Traits as Trait guards/hooks + participant DB as SQLite + + Caller->>Store: moveTask(id, toColumn, opts) + Store->>Store: resolve task's workflow → column graph + alt opts.bypassGuards (engine/recovery move) + Store->>Store: skip guards & abort-on-exit + else user/workflow move + Store->>Traits: guard(from, to) — sync, fast, pure + Traits-->>Store: ok | TransitionRejection{code, msgKey, retryable} + end + Store->>DB: write column + transitionPending marker (one txn,
capacity check enforced here) + Store-->>Caller: moved (or typed rejection) + Store->>Traits: onExit(from) / onEnter(to) — async, idempotent + Traits->>DB: clear transitionPending on success + Note over Traits,DB: crash here → recovery re-runs idempotent
hooks from transitionPending marker +``` + +### Worked example — the "Planning hold" workflow (directional) + +``` +[Planning col] [Todo col] [In-Progress col] [Review col] [Done col] + traits: hold traits: wip(planning), hold traits: wip(2), traits: traits: + abort-on-exit human-review complete + hold ──(manual)──► prompt: plan ──► hold ──(capacity)──► seam: execute ──► seam: review ──► seam: merge ──► end + (merge trait enqueues) +``` + +Cards rest in Planning untouched → manual promote releases them → the planning prompt runs while the card sits in Todo → card rests as "ready" → pulled into In-Progress when a WIP slot frees → review under a human-review-trait column → merge trait enqueues onto the merge-request queue → card lands in the `complete`-flagged column. This sketch is directional guidance, not implementation specification. + +--- + +## Requirements + +**Column & workflow model** + +- R1. A workflow defines an ordered list of columns; each column has an ID, display name, and a set of trait configurations. +- R2. Workflow nodes are placed in columns; a card's board position derives from its current column (persisted in `tasks."column"`), which the workflow's graph and traits drive. +- R3. A `hold` node kind expresses passive dwell, with release conditions: manual promote, timer, downstream capacity available. +- R4. Column validity is workflow-scoped: the closed `Column` union and `VALID_TRANSITIONS` constant are replaced by per-workflow column graphs (legacy path retained behind the flag until graduation). + +**Traits** + +- R5. A trait is composable configuration: declarative flags plus optional lifecycle hooks (`guard`, `onEnter`, `onExit`, `releaseCondition`). +- R6. Built-in and plugin-contributed traits register through one registry interface; plugins declare traits via manifest contributions. +- R7. The built-in trait set is the Trait Vocabulary table: `intake`, `complete`, `archived`, `merge-blocker`, `wip`, `hold`, `human-review`, `gate`, `merge`, `abort-on-exit`, `reset-on-entry`, `timing`, `stall-detection`, `notify`. +- R8. Trait composition is validated at workflow save time; conflicting combinations are rejected with actionable messages. + +**Policy inversion** + +- R9. State transitions, retries, WIP/capacity limits, hold/release, and drag semantics are workflow-expressed; no engine code keys policy off literal column names when the flag is on. +- R10. Merge strategy, squash posture, and file-scope enforcement mode are workflow-configurable via the merge trait; the three lost-work guards remain capability-level and non-configurable. +- R11. The substrate retains mechanisms only: worktree/git/session operations, semaphore/leases, persistence, crash recovery, audit, machine resource ceilings. + +**Invariant preservation** + +- R12. The built-in default workflow reproduces current behavior verbatim: `VALID_TRANSITIONS` parity, FN-5147 terminal-until-merged, `in-progress → todo` hard-cancel, in-review stall detection, file-scope guard, squash contract — machine-checked by parity tests before graduation. +- R13. `moveTaskInternal` remains the single transition authority; guard rejections are typed (`TransitionRejection`) across all surfaces. +- R14. Engine-sourced moves (`moveSource: "engine"`) bypass trait guards and abort-on-exit effects; self-healing remains the single recovery authority. +- R15. No card is ever stranded: trait hook failure, plugin disable, workflow edit/delete, and crash mid-transition all have defined recovery paths. + +**Board & surfaces** + +- R16. The dashboard board renders one lane per workflow in use by visible cards (tasks without a selection appear in the default-workflow lane); every card appears in exactly one lane. +- R17. Drag-and-drop semantics are trait-defined; rejected drops surface the typed rejection reason to the user. +- R18. The CLI TUI degrades gracefully: cards in columns it doesn't recognize map by trait flags into its existing buckets or a read-only "other" bucket — never silently disappear. (Mobile embeds the dashboard web UI and inherits R16/R17.) + +**Migration & rollout** + +- R19. Migration is additive-only, forward-only, idempotent, and rewrites zero task rows (KTD-1); the whole model is gated by `experimentalFeatures.workflowColumns`. +- R20. Workflow edit/delete/switch with live cards follows a defined reconciliation policy (U5); no operation leaves a card in a column its workflow doesn't define. + +**Parallelism & trait governance** + +- R21. Workflow graphs support `split`/`join` parallel branches per KTD-11: join modes `all | any | quorum(n)`, fail-fast or collect failure policies, per-branch crash-recoverable state, seam nodes forbidden inside branches, and the card's board position never forks. +- R22. Plugin traits are restricted to async hook points (`gate`, `onEnter`, `onExit`, `releaseCondition`); the sync `guard` hook point and the `complete`/`archived` flags are built-in-only (KTD-2, KTD-7). + +--- + +## Implementation Units + +Phased for independent landability. Phases A–C make the model real behind the flag; D–E extend policy coverage; F ships the surfaces; G migrates and graduates. + +### Phase A — Column & trait model (core) + +### U1. WorkflowIr v2: columns, node placement, hold nodes + +- **Goal:** Extend the IR so workflows define columns and place nodes in them, with a `hold` node kind and release conditions plus `split`/`join` parallel-branch nodes, while v1 graphs keep parsing. +- **Requirements:** R1, R2, R3, R4 (model half), R21 (IR half) +- **Dependencies:** none +- **Files:** `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/workflow-definition-types.ts`, `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-workflows.ts`, `packages/core/src/__tests__/workflow-ir.test.ts`, `packages/core/src/__tests__/builtin-workflows.test.ts` +- **Approach:** Bump IR to `version: "v2"` (FN-5769 froze v1 — this is the explicit, budgeted contract change). Add `columns: [{id, name, traits: [{trait, config}]}]`, `node.column` placement, `kind: "hold"` with `release: manual | timer | capacity | dependency | external-event` config, and `kind: "split"` / `kind: "join"` (join config: `mode: all | any | quorum(n)`, `onBranchFailure: fail-fast | collect`). Validator rules for parallelism (KTD-11): every split has a reachable matching join (recursively for nested splits); `execute`/`merge` seam nodes inside a branch reject with a named error. `parseWorkflowIr` upgrades v1 graphs by synthesizing default-workflow columns and placing nodes by their seam (execute → `in-progress`, review → `in-review`, merge → `in-review`, others → `todo`) — this read-path upgrade also covers v1 IR JSON already persisted in `workflows` rows (no row rewrite; upgraded shape persists on next save). Extend `BUILTIN_CODING_WORKFLOW_IR` into the full default workflow: six columns whose IDs are the legacy enum values (KTD-1), traits matching legacy semantics, intake/hold/seam nodes reproducing the current pipeline. +- **Patterns to follow:** existing `parseWorkflowIr` validation + `WorkflowIrError` shape; `builtin:` ID prefix and read-only semantics from `builtin-workflows.ts`. +- **Test scenarios:** + - v2 graph with columns/placement/hold parses; node referencing an undefined column ID rejects with a named error. + - v1 graph parses and upgrades: nodes land in synthesized default columns by seam; round-trips through the editor mapping unchanged. + - Hold node with each release kind (manual/timer/capacity/dependency/external-event) parses; unknown release kind rejects. + - Split/join: balanced split-join parses (incl. one nested level); split without reachable join rejects; execute or merge seam node inside a branch rejects with the seam-in-branch error; quorum(n) with n exceeding branch count rejects. + - Default workflow: column IDs exactly equal the legacy enum values, in legacy order; built-in remains non-editable/non-deletable. + - Duplicate column IDs within a workflow reject at parse. +- **Verification:** `pnpm test` green in core; v1 fixtures from existing tests still parse. + +### U2. Trait registry and built-in trait definitions + +- **Goal:** One registry resolving trait IDs to definitions (flags + hooks) for both built-ins and (later) plugins; ship the built-in set. +- **Requirements:** R5, R6, R7, R8 +- **Dependencies:** U1 +- **Files:** `packages/core/src/trait-types.ts` (new), `packages/core/src/trait-registry.ts` (new), `packages/core/src/builtin-traits.ts` (new), `packages/core/src/__tests__/trait-registry.test.ts` (new), `packages/core/src/__tests__/builtin-traits.test.ts` (new) +- **Approach:** `TraitDefinition { id, flags, hooks? }` with flags like `countsTowardWip`, `complete`, `archived`, `hiddenFromBoard`, `abortOnExit`, `humanReview`, `intake`, and hook descriptors per KTD-2 (two guard classes: sync guard built-in-only; async gate for plugins). Built-ins: the full Trait Vocabulary table (`merge` config stub here; behavior in U7). Restricted-flag enforcement: registry rejects non-builtin registrations declaring `complete`/`archived`/sync-guard (R22). Save-time composition validator (R8): reject nonsense combos (e.g., `complete` + `countsTowardWip`, two capacity traits on one column) with named reason codes; re-validate persisted workflows at load and degrade (audit + advisory) rather than brick definitions that predate a newly added rule. Registry follows the DI/static-import conventions (no dynamic `@fusion/engine` imports; engine registers hook implementations into core's registry the way `setCreateFnAgent` does). +- **Patterns to follow:** `createDefaultNodeHandlers(seams, runCustomNode)` handler-injection shape in `packages/engine/src/workflow-node-handlers.ts`; core→engine DI seam (`setCreateFnAgent`). +- **Test scenarios:** + - Registering a duplicate trait ID rejects; `builtin:` namespace cannot be overridden by a non-builtin registration. + - Composition validator: each documented conflict pair rejects with its reason code; a valid default-workflow column set passes. + - Flag resolution: a column's effective flags are the merged flags of its traits; conflicting boolean flags reject at save, not at runtime. + - Hook descriptor without a registered implementation resolves to a no-op with an audit warning (degraded, not crashed). + - Restricted flags: a plugin-namespace registration declaring `complete: true` (or `archived`, or a sync guard) rejects with the restricted-flag reason code. + - Load-time re-validation: a persisted workflow violating a newly added composition rule loads degraded with an audit event, not an error. +- **Verification:** core tests green; default workflow's columns validate through the composition validator. + +### U3. Typed transition contract and `transitionPending` marker + +- **Goal:** The shared transition rejection type and the crash-safe hook protocol, before any behavior moves. +- **Requirements:** R13, R15 +- **Dependencies:** U1 +- **Files:** `packages/core/src/transition-types.ts` (new), `packages/core/src/db.ts` (migration slot: `tasks.transitionPending` JSON column via `addColumnIfMissing`), `packages/core/src/__tests__/transition-types.test.ts` (new) +- **Approach:** `TransitionRejection { code, messageKey, retryable }` + a `TransitionResult` union; reason codes for guard-rejected, capacity-exhausted, unknown-column, workflow-mismatch, merge-blocked. `transitionPending` persists `{toColumn, hooksRemaining, startedAt}` written in the same transaction as the column change (KTD-2); cleared when post-commit hooks complete. Happy-path dispatch owner: `moveTaskInternal` itself schedules the hook runner immediately after commit (fire-and-forget with audit); the recovery sweep is the backstop for crashes, not the primary driver. Additive-only migration in the next version-gated slot. +- **Patterns to follow:** `addColumnIfMissing` migration pattern in `packages/core/src/db.ts`; existing typed-error conventions. +- **Test scenarios:** + - Each rejection code serializes/deserializes across the API boundary shape. + - Marker lifecycle: set in-txn with the move, cleared after hooks; a simulated crash (hooks never run) leaves the marker recoverable with `hooksRemaining` intact. + - SQLite-authoritative recovery (KTD-2): crash between the SQLite commit and the post-txn `task.json` write — recovery reconciles from the SQLite row, not the stale JSON. + - `hooksRemaining` referencing a now-uninstalled plugin hook: recovery clears the entry with a degraded-hook audit event and completes the marker — the card is never stuck waiting for a missing handler. + - Migration is idempotent (runs twice without error) and a no-op on a fresh DB. +- **Verification:** core tests green; schema fingerprint compatibility check passes. + +### Phase B — Transition authority cutover + +### U4. Workflow-resolved transitions in `moveTaskInternal` + default-workflow parity + +- **Goal:** Replace `VALID_TRANSITIONS` lookup with workflow column-graph validation plus trait guards, behind the flag, with the default workflow proving verbatim parity. +- **Requirements:** R4, R9 (transition half), R12, R13, R14 +- **Dependencies:** U1, U2, U3 +- **Files:** `packages/core/src/store.ts` (`moveTaskInternal`, `handoffToReview`), `packages/core/src/board.ts`, `packages/core/src/task-merge.ts` (terminal-guard reads `getTaskMergeBlocker`), `packages/core/src/workflow-parity.ts`, `packages/core/src/__tests__/move-task-workflow.test.ts` (new), `packages/core/src/__tests__/transition-parity.test.ts` (new) +- **Approach:** Flag on: resolve the task's workflow (null selection → default workflow), validate the move against its column graph, run trait guards sync-in-lock, enforce capacity in-txn (KTD-10 — enforcement lands fully in U6; the txn-time check slot is created here), write `transitionPending`, run effects post-commit. Flag off: legacy path byte-identical. Engine moves carry `bypassGuards` (KTD-9), subsuming `skipMergeBlocker` — the terminal-guard trait on `in-review` reads the same `getTaskMergeBlocker`, and `handoffToReview`'s bypass maps onto `bypassGuards`. Legacy per-column side effects (timing/`cumulativeActiveMs`, reopen field resets, autoMerge stamping + merge-queue enqueue) become the default workflow's trait hook implementations — moved, not duplicated. **Worktree allocation is explicitly NOT migrated to hooks**: it stays a substrate capability invoked synchronously by the scheduler *before* the move (the scheduler depends on allocation-before-session ordering; a post-commit async hook would break it). `bypassGuards` is engine-internal: API move endpoints hardcode it off and never forward a caller-supplied value (same posture as the existing hardcoded `moveSource: "user"`). +- **Execution note:** Characterization-first. Before changing `moveTaskInternal`, add a characterization suite capturing current `VALID_TRANSITIONS` outcomes and side effects for every (from, to, moveSource) combination that has a call site; the trait-driven path must reproduce it exactly. +- **Test scenarios:** + - Transition-parity: for every legacy (from, to) pair, flag-on default-workflow validation matches `VALID_TRANSITIONS` exactly (allowed and rejected sets identical). + - FN-5147: `autoMerge:false` task in `in-review` — engine-sourced backward moves rejected/annotation-only exactly as today; user move to `done` blocked by the terminal-guard with the merge-blocked rejection code. + - Hard-cancel: user drag `in-progress → todo` triggers abort-on-exit (session abort) and sets `userPaused`; engine-sourced same move bypasses abort-on-exit and does not set `userPaused`. + - `handoffToReview` succeeds with `bypassGuards` mapping; autoMerge stamping + merge-queue enqueue fire via the default workflow's onEnter hook identically to legacy. + - Guard rejection returns typed `TransitionRejection` (not a thrown string); legacy flag-off path still throws the legacy strings (no behavior change while off). + - Crash-mid-transition: marker present, hooks re-run idempotently on recovery sweep; double-running onEnter is a no-op. + - Unknown column for the task's workflow → `unknown-column` rejection, card untouched. + - Worktree ordering: under the flag, a scheduled pickup allocates the worktree before the move commits and before session start (the not-a-hook classification above). + - `bypassGuards` hardening: the HTTP move endpoint ignores a caller-supplied `bypassGuards: true`. + - Capacity deliberately unenforced until U6: a documenting test asserts the U4 capacity-check slot is a pass-through, and no U4 flag-on test exercises a WIP-constrained scenario (prevents misleading green between U4 and U6 landings). + - Handoff enqueue exactly-once: simulated crash between the column commit and the merge-enqueue onEnter hook → recovery re-runs the hook and the queue holds exactly one entry. +- **Verification:** characterization + parity suites green flag-on and flag-off; full existing store/self-healing/scheduler suites green with flag off (zero behavior change) and with flag on (parity). + +### U5. Workflow lifecycle reconciliation: switch, edit, delete with live cards + +- **Goal:** Defined behavior whenever a card's column could stop existing under it. +- **Requirements:** R15, R20 +- **Dependencies:** U4 +- **Files:** `packages/core/src/store.ts` (workflow CRUD + selection paths, `deleteWorkflowDefinition`), `packages/core/src/workflow-reconciliation.ts` (new), `packages/core/src/__tests__/workflow-reconciliation.test.ts` (new), `packages/dashboard/src/routes/register-workflow-routes.ts`, `packages/dashboard/src/routes/register-task-workflow-routes.ts` +- **Approach:** Policy: (a) **workflow switch** — card maps to the new workflow's entry (intake-flagged or first) column unless the new workflow defines a column with the same ID, which is preserved; in-flight processing nodes are aborted via the same abort-on-exit machinery first. (b) **workflow edit removing an occupied column** — save is blocked with a typed error listing occupant counts, plus an explicit "save and re-home occupants to column X" option in the API contract. (c) **workflow delete** — extends the existing cascade (`deleteWorkflowDefinition`): blocked for built-ins as today; for custom workflows, occupants re-home to the default workflow's entry column with selection rows cleared, one audit event per card. No path leaves a card in an undefined column (invariant test). +- **Test scenarios:** + - Switch with same-ID column preserves position; without, lands in entry column; active session aborted first. + - Edit removing occupied column blocks with occupant count; re-home option moves all occupants and emits audits. + - Delete with occupants re-homes to default entry, clears selection, preserves task fields (`preserveProgress` semantics). + - Property-style invariant: after any sequence of switch/edit/delete operations, every task's column exists in its resolved workflow. + - Concurrent move-vs-delete: task lock ordering means the card ends either moved-then-re-homed or re-homed; never lost. +- **Verification:** reconciliation suite green; existing workflow CRUD tests green. + +### Phase C — Scheduling & capacity + +### U6. Capacity enforcement and the hold/release sweep (generalized scheduler) + +- **Goal:** WIP/capacity as trait config enforced in-txn; hold release conditions evaluated by a substrate sweep; dependency satisfaction via complete-flag. +- **Requirements:** R3 (behavior half), R9 (capacity half), R11 +- **Dependencies:** U4 +- **Files:** `packages/engine/src/scheduler.ts`, `packages/engine/src/concurrency.ts`, `packages/core/src/store.ts` (in-txn capacity check), `packages/engine/src/hold-release.ts` (new), `packages/engine/src/__tests__/hold-release.test.ts` (new), `packages/engine/src/__tests__/scheduler.test.ts` +- **Approach:** Flag on, the scheduler becomes the hold/release sweep: for each workflow in use, evaluate hold nodes' release conditions (manual flags, timers via fake-timer-friendly clock, capacity-available against per-column WIP config) and call `moveTask` for eligible cards — releases serialize through the in-txn capacity check (KTD-10), with `AgentSemaphore` still gating actual session starts. Legacy `maxConcurrent` maps to the default workflow's `in-progress` WIP config so settings carry over. Sweep moves carry `moveSource: "scheduler"` and reserve worktree + semaphore before issuing the move, releasing the reservation on a capacity rejection (KTD-10). Dependency gating switches to complete-flag with FN-5719 dual-accept (handoff marker OR complete column) + audit-diff logging; the dual-accept window **closes at graduation** (U12), and any marker/column disagreement above zero during the observation period blocks graduation rather than just logging. The hold/release sweep inherits the scheduler's existing poll-interval setting. `computeConcurrencyGateDiagnostic` generalizes to per-column capacity gates, preserving the three-gate report shape. +- **Test scenarios:** + - Two holds, one slot: exactly one releases (txn-time check); the other releases on the next sweep after the slot frees. + - Timer release fires at its deadline under fake timers; manual release only on the explicit promote call. + - Capacity-available release respects downstream WIP including cards mid-`transitionPending`. + - Dependency in a custom workflow's complete-flagged column unblocks a dependent in another workflow; dual-accept logs a diff when marker and column disagree. + - Legacy parity: flag-on default workflow with `maxConcurrent: 2` schedules identically to flag-off legacy scheduler (same pickup order, same gating diagnostics). + - Paused/recovery-backoff tasks (`nextRecoveryAt`) are skipped exactly as today. + - Sweep release into a full column is rejected by the in-txn capacity check even though `moveSource: "scheduler"` bypasses trait guards — capacity is not a guard. + - Interleaving: column capacity check passes but the semaphore is exhausted — the reservation-first ordering means the move never commits; reservation released, card stays held. + - In-txn WIP count includes cards mid-`transitionPending` (they hold their destination slot from commit time). +- **Verification:** scheduler + hold-release suites green; no slow tests (fake timers per FN-5048). + +### U13. Fan-out/join branch execution + +- **Goal:** `WorkflowGraphExecutor` walks `split`/`join` graphs concurrently with crash-recoverable per-branch state. +- **Requirements:** R21 +- **Dependencies:** U1, U4 +- **Files:** `packages/engine/src/workflow-graph-executor.ts` (extend existing), `packages/engine/src/workflow-graph-task-runner.ts` (extend existing), `packages/core/src/db.ts` (per-branch run-state persistence, additive), `packages/engine/src/__tests__/workflow-graph-fanout.test.ts` (new) +- **Approach:** Branch walking via concurrent node execution per branch with per-branch persisted state (branch id, current node, status) written through the existing run-state path so a restart resumes each branch where it died (ADR-0001 reconstructibility). Join synchronization per KTD-11 (`all | any | quorum(n)`); `fail-fast` cancels sibling branches through the same abort machinery as `abort-on-exit`; `collect` waits and exposes branch outcomes to the join's outgoing edge conditions. The card's column stays at the split's column during parallel execution, advancing on join resolution (KTD-11); branch progress is exposed on the task record for U9's per-branch badges. Branch node sessions remain bounded by `AgentSemaphore` + node capacity. +- **Test scenarios:** + - Two-branch split, `mode: all`: both branches complete (any order, fake timers), join fires once, card advances to the join's column. + - `mode: any`: first branch completion fires the join; the slower branch is cancelled (fail-fast) or allowed to finish without re-firing the join (collect). + - `quorum(2)` of 3: join fires on the second completion; third branch handled per failure policy. + - Branch failure under `fail-fast`: siblings receive aborts; join routes the failure edge. Under `collect`: all branches finish; join evaluates combined outcomes. + - Crash mid-branch: restart resumes the incomplete branch from its persisted node without re-running completed branches' nodes (idempotency assertion). + - Card position invariant: task's column equals the split's column for the entire parallel window, never a branch node's column. + - Concurrency bound: branches queue on `AgentSemaphore` when slots are exhausted rather than oversubscribing. +- **Verification:** fan-out suite green; existing sequential-walk executor tests unchanged (no regression for linear graphs). + +### Phase D — Merge & policy traits + +### U7. Merge trait: enqueue-only orchestration with configurable policy + +- **Goal:** Merge/PR orchestration, merge strategy, squash posture, and file-scope mode become merge-trait configuration over the substrate merge capability. +- **Requirements:** R10 +- **Dependencies:** U4 +- **Files:** `packages/engine/src/merge-trait.ts` (new), `packages/engine/src/merger.ts` (read policy from trait config; mechanics untouched), `packages/engine/src/__tests__/merge-trait.test.ts` (new), `packages/core/src/builtin-traits.ts` (merge trait config schema) +- **Approach:** The merge trait's onEnter enqueues onto the persisted merge-request queue and resolves via the queue's completion callback — never awaited inside a graph walk or a transition (KTD-6, the deadlock hazard plan 002 flagged). Config: `strategy` (maps onto existing `directMergeCommitStrategy` values + PR-only), `fileScope` (`strict`/`warn`/`off`/custom rules → feeds the existing `FileScopeViolationError` check and `scopeOverride` path), `squash` posture, `conflictStrategy`. Existing settings knobs become the default workflow's merge-trait config (settings read-through for back-compat). The merge trait's `onExit` absorbs the existing `dequeueMergeQueueOnColumnExit` behavior (leaving the merge column dequeues a pending request) — moved, not duplicated. The `fileScope: "warn"` audit event carries the violating file list (same payload as `FileScopeViolationError`); `fileScope: "off"` still emits one per-merge audit event recording that scope enforcement was disabled by workflow config, and per-task `scopeOverride` is a documented no-op in that mode. The three lost-work guards stay in `merger.ts` mechanics, unreachable from config. +- **Test scenarios:** + - Each strategy value routes to the existing merger behavior it names; PR-only enqueues a PR flow without direct merge. + - `fileScope: "off"` skips the violation throw; `"warn"` logs + proceeds; `"strict"` matches today; custom rules evaluated. + - Lost-work regression trio: sibling `fusion/fn-*` merge target rejected regardless of config; attribution remains line-anchored; no-op finalize with claimed work blocks and preserves `modifiedFiles` (moves back with progress, emits `task:finalize-lost-work-blocked`). + - Merge completion drives the card to the next column via the queue callback, not inline; a queued merge surviving restart resumes from SQLite state. +- **Verification:** merge-trait + existing merger suites green; lost-work regression tests in place. + +### Phase E — Pluggable traits + +### U8. Plugin-contributed traits + +- **Goal:** Plugins declare traits; hooks execute through existing machinery; live-dependent protection. +- **Requirements:** R6, R15 (plugin half) +- **Dependencies:** U2, U4 +- **Files:** `packages/core/src/plugin-types.ts` (`PluginTraitContribution`), `packages/engine/src/plugin-runner.ts` (aggregation + disable guard), `packages/plugin-sdk/src/index.ts` (re-exports), `packages/engine/src/__tests__/plugin-traits.test.ts` (new), `docs/PLUGIN_AUTHORING.md` +- **Approach:** `PluginTraitContribution { traitId, name, flags, schemaVersion, hooks: {gate?, onEnter?, onExit?, releaseCondition?} }` (async hook points only, R22; restricted flags rejected at validation) with a **versioned hook-descriptor schema** so the vocabulary extends additively without breaking published traits. Validated like `PluginWorkflowStepContribution`; `PluginRunner` aggregates/caches/invalidates on `plugin:registered/unregistered`. Hook execution routes through the prompt-session/script/verdict machinery with `gateMode` semantics for gates (advisory vs blocking, fail-closed for blocking gates per the existing gate handler) — gates evaluate pre-move outside the lock per KTD-2. Disable/uninstall with live dependents → blocked with occupant detail; force → columns degrade to passive with audit (KTD-7). +- **Test scenarios:** + - Contribution validation rejects malformed trait manifests; valid ones resolve through the same registry lookup as built-ins. + - Plugin gate in blocking mode rejects a move via its pre-evaluated verdict (typed rejection); advisory mode records and allows; a plugin contribution declaring a sync `guard` hook rejects at validation. + - Disable with cards in a plugin-trait column blocks; force-disable degrades the column (hooks no-op, audit emitted, cards still movable). + - Plugin trait throwing inside onEnter: card stays in column, `transitionPending` cleared with a degraded-hook audit, no wedged lock. Assert against real engine wiring, not mocks of nonexistent methods (branch-group dead-wiring lesson). +- **Verification:** plugin-trait suite green; `docs/PLUGIN_AUTHORING.md` documents the contribution. + +### Phase F — Surfaces + +### U9. Multi-lane dashboard board + +- **Goal:** Lane per workflow in use; workflow-defined columns; trait-aware drag with typed rejection feedback. +- **Requirements:** R16, R17 +- **Dependencies:** U4, U5 +- **Files:** `packages/dashboard/app/components/Board.tsx`, `packages/dashboard/app/components/Column.tsx`, `packages/dashboard/app/components/TaskCard.tsx`, `packages/dashboard/app/components/Lane.tsx` (new), `packages/dashboard/app/utils/taskSorting.ts`, `packages/dashboard/app/hooks/useTasks.ts`, `packages/dashboard/src/routes/register-task-routes.ts` (typed rejection in move endpoint), `packages/dashboard/app/components/__tests__/Board.test.tsx`, `packages/dashboard/app/components/__tests__/Lane.test.tsx` (new) +- **Approach:** Flag on: group visible tasks by resolved workflow (null → default lane); `Lane.tsx` renders one workflow's columns from its definition; `Board.tsx`'s `COLUMNS.map` becomes lanes-of-columns. **Lanes stack vertically** — each lane is a full-width row containing its own horizontally-scrollable columns (contains the existing iOS scroll-stabilization behavior per lane instead of compounding it). **Zero-card lanes are hidden**; lanes are **collapsible with persisted collapse state** (the lane-density mitigation for many-workflow boards — the default lane stays primary). Lane header shows the workflow name + card count. Flag off: current single-lane board unchanged. Drag rejections: deterministic guard/capacity rejections are **no-move** (the card never renders in the target column); async merge-blocked rejections use optimistic move + snap-back, both surfacing the typed rejection's `messageKey` via i18n. Archived-flagged columns hidden per lane. Hold columns show a promote affordance with explicit states: loading/disabled during the call, capacity-exhausted shows inline column feedback (not a toast — multiple holds can promote concurrently), success moves optimistically; the promote/release endpoint ships in this unit's route file. **Workflow-switch UI**: switching a card's workflow with an active session shows a confirmation warning of abort + re-home (parallels the existing preserve-progress confirm in `Column.tsx`). `Column.tsx` bulk actions re-key from column-ID literals to trait-flag predicates. Cross-lane drag is rejected with a `workflow-mismatch` rejection pointing at the workflow-switch flow (drag never implicitly switches workflows). Cards in a parallel window render per-branch progress badges (U13's exposed branch state). SSE/`useTasks` flow unchanged; lane grouping is client-side derivation. All new strings `t()`-wrapped; follow existing lazy-load/CSS-token conventions (no new monolith CSS). +- **Test scenarios:** + - Tasks with no selection render in the default lane; each card appears in exactly one lane (R16 invariant test over a mixed fixture). + - Lane renders its workflow's columns in order; archived-flagged column hidden. + - Rejected drop (guard/capacity/merge-blocked) shows the translated rejection and snaps back; allowed drop optimistically moves. + - Manual-release promote button releases a hold card (calls the promote endpoint); capacity-exhausted promote shows inline feedback and re-enables. + - Zero-card lane is hidden; collapse state persists across reloads; lane header shows workflow name + count. + - Cross-lane drag rejects with the workflow-mismatch message; workflow switch with an active session shows the abort-warning confirmation. + - Flag off renders the legacy board byte-identically (snapshot). +- **Verification:** dashboard tests green; i18n extract shows no unwrapped strings; manual smoke via the running dashboard (do not kill the live instance on port 4040). + +### U10. Node editor: columns, traits, and hold nodes + +- **Goal:** Author columns/traits/placement in the existing React Flow editor. +- **Requirements:** R1, R2, R3 (authoring), R8 (surfacing validation) +- **Dependencies:** U1, U2 +- **Files:** `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx`, `packages/dashboard/app/utils/workflow-flow-mapping.ts`, `packages/dashboard/app/components/WorkflowColumnPanel.tsx` (new), `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` +- **Approach:** Columns render as React Flow group/swimlane backgrounds; dragging a node into a column band sets `node.column`. A column panel manages add/rename/reorder and trait assignment (trait picker fed by the registry's catalog endpoint — same session-scoped access pattern as existing workflow routes, no new auth surface). Hold node type with release-condition config UI; split/join node types with join-mode and failure-policy config. Save round-trips `flowToIr`/`irToFlow` through `parseWorkflowIr` + the composition validator. Error surfacing: column-level violations render on the offending column; **unplaced-node errors render as an inline badge on the node** and block save with a summary count — both use the same error-state component. Read-only built-in banner replaces the save/edit toolbar with "Duplicate to customize" as its primary action, canvas stays inspectable. +- **Test scenarios:** + - `irToFlow`/`flowToIr` round-trips v2 columns, placement, hold config, and split/join config losslessly. + - Seam-in-branch validation error renders on the offending node at save. + - Dropping a node into a column band updates placement; saving a node outside any column is rejected with the parse error surfaced. + - Trait conflict from the validator renders on the column; save blocked until resolved. + - Editing the built-in default workflow remains blocked (read-only banner), with "duplicate to customize" affordance. +- **Verification:** mapping + editor tests green; manual editor smoke creating the worked-example workflow. + +### U11. CLI TUI graceful degradation + +- **Goal:** The TUI never drops cards in custom columns. +- **Requirements:** R18 +- **Dependencies:** U4 +- **Files:** `packages/cli/src/commands/dashboard-tui/app.tsx` (`KANBAN_COLUMNS` reconciliation), `packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx` +- **Approach:** Map unknown columns by trait flags into the TUI's existing buckets (wip-flagged → in-progress bucket, human-review/merge-blocker → in-review, complete → done, hold/intake → todo) and add a read-only **"Other (custom)"** bucket — ordered after in-review, before done — for unmapped ones; cards in it show their actual column name as a secondary label so users keep the real position. Moves from the TUI into custom columns it can't express are disabled with a hint. (Mobile embeds the dashboard web UI and inherits U9; `packages/mobile/` has no task-list views — see Scope Boundaries.) +- **Test scenarios:** + - Fixture with cards in custom columns: every card visible in some bucket; none dropped (the silent-disappearance regression test). + - Trait-flag mapping places each built-in trait correctly; unmapped column lands in "Other (custom)" with its column name as secondary label, in the in-review→done position. + - TUI move of a card in an unexpressible column is disabled with hint text. +- **Verification:** CLI tests green; TUI manual smoke (`fn` dashboard view; never SIGKILL live processes per repo policy). + +### Phase G — Migration & graduation + +### U12. Flag-gated cutover, migration, parity graduation, and supersession cleanup + +- **Goal:** Ship the model end-to-end behind `workflowColumns`, migrate, prove parity, default the flag on; absorb plan 002's M-D. +- **Requirements:** R12 (graduation half), R19 +- **Dependencies:** U4–U11, U13 +- **Files:** `packages/core/src/db.ts` (final migration slot), `packages/core/src/types.ts` (legacy constants marked deprecated, retained while flag exists), `packages/engine/src/workflow-parity-observer.ts` (extend existing), `packages/core/src/workflow-parity.ts` (extend existing), `docs/architecture.md`, `docs/workflow-steps.md`, `CONCEPTS.md`, `packages/core/src/__tests__/migration-workflow-columns.test.ts` (new) +- **Approach:** Migration: additive schema only (already landed incrementally in U3/U6); zero task-row rewrites (KTD-1); null selection resolves to default workflow at read time, so "migration" is mostly the resolution rule plus an idempotent integrity pass that audits any task whose stored column isn't valid in its resolved workflow (re-homing via U5's policy). Graduation: dual-observe on real runs via the extended parity machinery; flip `workflowColumns` default when the parity suite and field observations show zero drift across the five invariants + transition parity, **and** zero dual-accept marker/column disagreements (U6) over the observation period — closing the dual-accept window at graduation. Update `docs/architecture.md`, `docs/workflow-steps.md`, and `CONCEPTS.md` (new concepts: column, trait, lane, hold, default workflow); mark plan 002 superseded. Legacy-pipeline code removal is deferred follow-up. +- **Test scenarios:** + - Migration idempotent; fresh DB and aged fixture DB (tasks in every legacy column, some with workflow selections) both resolve every task to a valid (workflow, column) pair. + - Tasks in `done`/`archived` untouched by the integrity pass. + - Flag off after migration: legacy board and engine behavior fully intact (rollback safety). + - Parity drift injected deliberately (e.g., altered default-workflow guard) is caught by the parity suite — proving the graduation gate actually gates. +- **Verification:** full `pnpm test` + `pnpm verify:workspace` green flag-on and flag-off; graduation checklist documented in the plan-execution notes; changeset added (published `@runfusion/fusion` is affected via CLI). + +--- + +## System-Wide Impact + +- **Affected surfaces (FN-5893 Surface Enumeration):** engine (store/scheduler/self-healing/merger/executor), dashboard board + editor + API routes, CLI TUI, mobile list views, plugin SDK, docs. Any invariant change must be asserted across all of them; U4/U6/U9/U11 carry the per-surface tests. +- **Data lifecycle:** `tasks."column"` semantics widen from enum to workflow-scoped ID (values unchanged for default-workflow tasks); new `transitionPending` column; no destructive schema change (DB-corruption history makes additive-only non-negotiable). +- **Recovery:** self-healing remains the single recovery authority (KTD-9); the `transitionPending` marker adds one new recovery responsibility (complete-or-rerun idempotent hooks), wired into the existing sweep. +- **Performance:** guard hooks run in-lock — they must stay sync/fast (KTD-2); the hold-release sweep replaces the scheduler poll at the same cadence, so steady-state load is comparable. + +--- + +## Risks & Dependencies + +- **~200-file literal-column surface** (initial grep: ~158 in research, closer to ~198 including dashboard components). The flag means flag-off behavior never changes, but flag-on coverage depends on finding every policy-bearing literal. Mitigation: U4's characterization suite, trait-flag predicate helpers (`isCompleteColumn(task)` style) replacing string equality, and a lint/grep audit gate in U12 for remaining literals on flag-on paths. +- **Fan-out branch state is new recovery machinery.** Per-branch persisted state (U13) adds a second recovery responsibility beyond `transitionPending`. Mitigation: same SQLite-reconstructible posture (ADR-0001), crash-resume tests per branch, and the seam-in-branch prohibition keeps worktree/merge mechanics out of the concurrent paths entirely. +- **Flag-on/flag-off dual maintenance until graduation.** Both paths must stay in sync for engine bugfixes during the window. Mitigation: the transition-parity suite runs on both paths in CI for every change (not only at graduation), so a fix landed on one path that diverges the other fails fast. +- **Merge rewiring regression risk (lost-work class).** Mitigation: KTD-6 keeps the three guards capability-level + U7's regression trio; merger mechanics are read-only in this plan. +- **Hook protocol is the new invariant.** `transitionPending` + idempotent hooks is the one genuinely novel reliability contract. Mitigation: U3/U4 crash-simulation tests; hooks observable in audit; degraded-not-stranded posture everywhere. +- **Long-lived-branch risk.** Mitigation: every unit lands independently behind the flag; phases A–C are merge-safe with zero flag-off behavior change. +- **IR v1 freeze (FN-5769).** v2 is a deliberate, budgeted contract change; v1 parse compatibility (U1) is the containment. +- **Dependency:** plan 002's M-A–M-C implementations (`workflow-graph-executor.ts`, `workflow-node-handlers.ts`, `workflow-graph-task-runner.ts`, executor wiring) are assumed present and stay load-bearing; this plan supersedes only its remaining M-D milestone. + +--- + +## Sources & Research + +- `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md` — completed MVP this builds on (IR, editor, selection). +- `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` — superseded; its seam characterization (review/merge plug-and-play; execute needed `runImplementationPhase` extraction; never compose full `execute()`) and KTDs are carried forward. +- `docs/rfcs/FN-5719-decouple-executor-merger.md` — handoff marker + persisted merge-request queue; the two enforcement points that must migrate (scheduler dependency satisfaction, in-review overlap leases) with dual-accept posture. +- `docs/dag/adr-0001-dag-orchestration.md` + `docs/dag/milestone-b-schema-migration-plan.md` — enqueue-only boundary; additive/forward-only migration rules; SQLite-reconstructible state. +- `docs/workflow-steps.md` — invariant bar and `gateMode` semantics generalized by trait hooks. +- `docs/incidents/2026-05-23-lost-work-tasks.md` — the three non-configurable merge guards (KTD-6). +- `docs/self-healing-backward-move-audit.md` (FN-5335) — triple-proof rule; single recovery authority (KTD-9). +- `docs/solutions/architecture-patterns/mass-migration-agent-fleet-orchestration.md` — behavior-invariant migration + clean-baseline diff triage. +- `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md` — no mock-masked engine assertions (U8 test posture). +- Key code anchors: `packages/core/src/types.ts:18` (`COLUMNS`), `packages/core/src/store.ts` (`moveTaskInternal`, workflow CRUD), `packages/engine/src/scheduler.ts` (`computeConcurrencyGateDiagnostic`), `packages/engine/src/workflow-node-handlers.ts` (handler-injection precedent for the trait registry), `packages/engine/src/plugin-runner.ts` (contribution aggregation precedent), `packages/cli/src/commands/dashboard-tui/app.tsx` (`KANBAN_COLUMNS`). diff --git a/docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md b/docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md new file mode 100644 index 0000000000..7b41265ecd --- /dev/null +++ b/docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md @@ -0,0 +1,411 @@ +--- +title: "feat: Step inversion — steps as workflow-modelable nodes" +type: feat +status: active +date: 2026-06-04 +depth: deep +origin: none (solo planning bootstrap; extends docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md) +--- + +# feat: Step inversion — steps as workflow-modelable nodes + +## Summary + +Extend the engine→workflow inversion to **task steps**. Today the engine owns step policy end-to-end: PROMPT.md `### Step N:` headings parse into `Task.steps[]`, the agent session executes them, per-step review happens through the in-session `fn_review_step` tool (verdicts `APPROVE | REVISE | RETHINK | UNAVAILABLE`), RETHINK does `git reset --hard` + session rewind + step reset, and merge is blocked while any step is non-terminal. After this plan, the substrate knows exactly one new thing — **how to run one step of a task inside its session and how to reset one step to its baseline** — and everything else (step granularity, per-step check/verdict, approval gates, rework/escalation routing) becomes user-authored workflow-graph structure: a runtime-expanding **`foreach` template region** instantiated once per planned step, a **`step-review` node** that surfaces review verdicts as outcome edges, and bounded **rework edges** that the executor permits as the only legal cycles. + +Steps additionally gain **parallel execution and worktree isolation as explicit foreach axes** (`mode: sequential|parallel`, `isolation: shared|worktree`): PROMPT.md steps may carry `depends:` metadata, and a parallel foreach runs dependency-satisfied instances concurrently — each in its **own worktree/branch off a common base** — with an **ordered integration stage** that lands step branches in step order and routes rebase conflicts to an `integration-conflict` rework outcome (KTD-11). + +Third, the **task shape itself becomes workflow-defined** (KTD-12/13/14): the existence of PROMPT.md and the `### Step N:` parsing convention stop being engine law — workflows declare their **artifacts** (named task documents), and **step parsing is itself a graph node**: `parse-steps(artifact, parser)` reads an artifact, runs a registry parser, and writes the step list, with routable `no-steps`/`parse-error` outcomes — and parsers are **plugin-pluggable**. A **`code` node** (KTD-15) runs arbitrary sandboxed TypeScript for logic no built-in covers. Workflows also declare **custom task fields** (typed, with enum options and rendering instructions); the task model reduces to core fields (title, description) plus standard metadata, with everything else as workflow-defined fields, and the task UI renders the field schema dynamically (detail form + card badges). + +The default workflow is untouched (monolithic `execute` seam, declares PROMPT.md + the step-headings parser + zero custom fields — byte-identical; it is the parity oracle, same posture as the columns track). Inversion is opt-in via custom workflows; a new built-in **stepwise coding workflow** demonstrates the full modeling and is the parity-comparison subject. + +--- + +## Problem Frame + +The columns/traits track (plan 2026-06-03-003, PR #1418) moved board policy — transitions, capacity, hold, merge orchestration — into workflow IR + traits. But **step policy is still engine law**: + +- The plan→steps breakdown is a hardcoded regex over PROMPT.md (`store.parseStepsFromPrompt`, `packages/core/src/store.ts:8534`). +- Per-step review is an in-session tool (`fn_review_step`, `packages/engine/src/executor.ts:7300+`) whose verdict handling — APPROVE auto-completes the step, REVISE re-prompts, RETHINK resets git + session + step — is fixed control flow inside a ~3k-line `execute()`. +- Rework routing (what happens after REVISE/RETHINK, how many times, who escalates) is not expressible by a user at all. +- The graph executor (`packages/engine/src/workflow-graph-executor.ts`) sees implementation as one opaque `execute` seam; a workflow cannot say "review each step with a read-only checker before the next step starts" or "send a rejected step to a senior-model rework node." + +A user who wants per-step plan review, a different verdict policy, a human approval between steps, or fan-out read-only checks per step has no modeling surface. That policy belongs in workflows; the engine should only supply the mechanisms (run step *i* in the session; reset step *i* to baseline; run the reviewer). + +The FN-4359 reliability-freeze waiver carried by plans 002/003 continues to apply to this track. The five lifecycle invariants (FN-5147 terminal-until-merged, hard-cancel, in-review stall, file-scope, squash) plus the lost-work guard trio remain the non-configurable correctness bar. + +--- + +## Scope Boundaries + +### In scope + +- New IR constructs (additive to WorkflowIr v2): `foreach` node kind with an inline per-step template subgraph; `step-execute` seam node (legal only inside a foreach template); `step-review` node kind; `rework` edge kind (the only legal cycle form); verdict outcome edges. +- Substrate capability extraction: `runTaskStep(task, stepIndex)` (run exactly one step inside the task's single session/worktree, commit with the existing `complete step N` convention) and `resetStepToBaseline(task, stepIndex, baselineSha)` (git reset + session rewind + `updateStep(...,"pending")`), both delegating to existing code, never reimplementing. +- Runtime template expansion with deterministic instance identity, persisted instance run-state (schema v108), crash/resume reconstruction, and a stale-instance recovery sweep. +- `Task.steps[]` kept as the **physical projection sink**: instance transitions call the existing `store.updateStep`, so the merge-blocker, dashboard step display, CLI/TUI, reconcile-from-git, and lost-work reset all keep working unchanged. +- **Parallel step execution** (KTD-11): `TaskStep.dependsOn` metadata parsed from PROMPT.md `### Step N (depends: 1,2):` annotations; foreach `concurrency` config; per-instance worktrees/branches off a common base via the existing worktree pool; ordered integration (rebase/cherry-pick in step order) with `outcome:integration-conflict` routed as rework on the updated base; branch-scoped RETHINK reset in parallel mode. +- **Workflow-defined task artifacts & a `parse-steps` node** (KTD-12): workflows declare named task documents (riding the existing task-documents machinery), and step parsing is a first-class graph node — `parse-steps(artifact, parser)` reads the artifact and writes the step list, with `no-steps`/`parse-error` outcome edges; `parseStepsFromPrompt` becomes the built-in `step-headings` parser in a parser registry; the default workflow keeps its legacy in-execute step init for byte-identical parity. +- **Workflow-defined custom task fields** (KTD-13): typed field definitions (string/text/number/boolean/enum/multi-enum/date/url) with enum options and rendering instructions in the IR; values stored per task and validated against the schema through a single store authority; agent-tool parity. +- **Pluggable step parsers**: plugins register parsers (namespaced ids) through the plugin runtime, mirroring the plugin-trait adapter; fail-closed to `parse-error`. +- **`code` node** (KTD-15): inline TypeScript compiled with esbuild, executed in a timeout-bounded child process against a narrow harness contract (`ctx` in, `{outcome, contextPatch, customFields}` out); save-time syntax validation. +- **Dynamic task UI** (KTD-14): TaskDetailModal renders the field schema as a form section; TaskCard renders card-front-placed fields as badges/chips; workflow editor gains a field-definitions panel. +- Built-in **stepwise coding workflow** (new, opt-in) modeling today's per-step review policy explicitly; parity assertions against legacy in-session behavior. +- Workflow node editor support for authoring foreach/step-review/rework constructs; agent-tool and plugin-SDK type parity; docs. + +### Out of scope (deferred for later) + +- Removing or rewriting the legacy in-session step path (`fn_review_step`, step-sessions). It remains the flag-OFF behavior and the default workflow's behavior, byte-identical. +- Re-expansion when the agent edits PROMPT.md after the foreach has expanded (instance count is pinned at expansion; documented limitation, surfaced via tool message — see KTD-3). +- Step-template authoring from the dashboard *board* (lanes/cards unchanged); authoring lives in the existing workflow node editor. +- Plugin-defined node kinds (plugins already reach gates via traits; new node kinds stay built-in this round). +- **Recasting existing built-in task fields** (priority, labels, etc.) as workflow custom fields — this plan ships the field *system*; migrating built-ins onto it is a follow-up with its own compatibility track (every built-in field has hardcoded consumers across ~150 files). The field system is designed so that migration is additive when it comes. +- Plugin-contributed field *types* (the field-type whitelist is built-in-only this round; step parsers ARE pluggable, KTD-12). +- Cross-workflow field identity (two workflows defining a field with the same id are distinct schemas; no shared/global field namespace yet). +- Graduating any flag default. + +--- + +## Key Technical Decisions + +### KTD-1 — Default workflow stays monolithic; inversion is opt-in (resolves the parity-vs-inversion contradiction) + +The built-in default coding workflow keeps its single `execute` seam node and is byte-identical flag-ON and flag-OFF (the existing characterization suites continue to prove this). Per-step modeling cannot be both "verbatim today" and "structurally inverted" — so the default is the **parity oracle**, and inversion ships as authoring capability plus a separate built-in **stepwise coding workflow** users can select. This mirrors exactly how the columns track kept `VALID_TRANSITIONS` as the deprecated-but-retained oracle. + +### KTD-2 — One new substrate seam pair: `runTaskStep` / `resetStepToBaseline` + +- `runTaskStep(task, stepIndex)` — drives execution of exactly step *i* and returns `{outcome, baselineSha, checkpointId}`. **Honest framing: this is part extraction, part new code.** The discrete per-step boundary (`onStepStart`/`onStepComplete`) exists today only in `StepSessionExecutor` (the `runStepsInNewSessions` path); the monolithic single-session path has no "run one step and return control" seam — the agent self-paces. Therefore: **graph-owned stepwise runs always use step-session physics** (`StepSessionExecutor`), regardless of the `runStepsInNewSessions` setting (pinned per run, like the flag in KTD-8). `runTaskStep` is a thin driver over `StepSessionExecutor` extracted characterization-first; no monolithic single-step driver is invented. The legacy monolithic path is untouched and remains the flag-OFF/default-workflow behavior. +- **Commit authorship is unchanged**: the step agent still authors its own `complete Step N — ` commits (the summary feeds `git log`, merger subject derivation, and `reconcileStepsFromGitHistory`, `executor.ts:11067`). `runTaskStep` **observes** the commit (captures the resulting SHA at step completion) — it never authors commits. +- **Baseline capture is a deliberate, documented behavior change**: today the RETHINK baseline is an agent-supplied tool parameter; in the inverted path the substrate runs `git rev-parse HEAD` at instance start. Because instances run sequentially (KTD-3), HEAD-at-instance-start is exactly the boundary after steps `0..i-1`'s commits — equivalent to what a well-behaved agent supplies today. U7's RETHINK parity test asserts the captured SHA matches the agent-equivalent baseline on scripted runs. +- `resetStepToBaseline(task, stepIndex, baselineSha, checkpointId?)` — the RETHINK mechanics, verbatim from `executor.ts:7455-7505`: `git reset --hard `, session rewind via `navigateTree`/`branchWithSummary` fallback, `updateStep(...,"pending")`. Partial-recovery behavior preserved: missing baseline → skip git reset; missing checkpoint → skip rewind (today's semantics, `executor.ts:7466,7492`). **Blast-radius guard** (shared isolation): because rework edges are intra-instance and shared-isolation instances are sequential, a reset for instance *i* can only fire while *i* is active — later instances have not run, and the per-instance baseline postdates steps `0..i-1`, so the reset can never destroy other steps' approved work. `resetStepToBaseline` asserts this invariant defensively (baseline is an ancestor of HEAD; no later instance row is `completed`) and refuses with an audited failure outcome if violated. Under worktree isolation (KTD-11) the reset targets the instance's own branch, making the guard structural. + +Crucially this **fixes the in-memory fragility**: today `stepCheckpoints`/`codeReviewVerdicts` are unsynchronized in-memory Maps lost on restart. `baselineSha`/`checkpointId` move into persisted instance run-state (KTD-6). The graph decides *when* a reset happens; the substrate owns *how*. + +### KTD-3 — `foreach` node with an inline template subgraph; expansion when the walk reaches it + +New node kind `foreach`, config: + +``` +{ + source: "task-steps", + maxReworkCycles?: number, + mode?: "sequential" | "parallel", // default "sequential" + concurrency?: number, // parallel mode only; default 2, cap 8 + isolation?: "shared" | "worktree", // default: "shared" for sequential, "worktree" for parallel + template: { nodes: [...], edges: [...] } +} +``` + +- `template` is an inline subgraph with exactly one entry and one exit (validated like the main graph; same `validateV2` rules applied recursively). +- Expansion happens **when the walk reaches the foreach node**: `source: "task-steps"` reads `Task.steps[]` at that moment — by then the planning seam / plan node has populated steps (today's `execute()` initializes steps from PROMPT.md before step work begins, `executor.ts:4263-4269`). +- **`mode` and `isolation` are explicit, independent authoring axes** (KTD-11 for the physics): + - `sequential` + `shared` (default): instances run in step order in the task's main worktree — the baseline physics every other KTD describes. + - `sequential` + `worktree`: instances still run one at a time, but each in its own worktree/branch with ordered integration — buys branch-scoped RETHINK and a clean per-step audit trail at sequential pace. + - `parallel` + `worktree`: dependency-aware concurrent execution (below). + - `parallel` + `shared`: **rejected by the validator** — concurrent write sessions in one worktree are unguardable races. +- **Parallel scheduling**: an instance becomes runnable when all of its step's `dependsOn` steps are integrated (KTD-11); up to `concurrency` runnable instances execute concurrently. `TaskStep.dependsOn?: number[]` is parsed from the PROMPT.md annotation `### Step N (depends: 1,2): Title`; a step with no annotation implicitly depends on the previous step, so an unannotated plan is fully sequential regardless of mode — parallelism is opt-in per step by the planner, not asserted globally by the workflow author. Dependency cycles are rejected at expansion with an audited failure. +- Instance identity is deterministic: `#:` — resume can reconstruct the full instance set from `(foreachNodeId, pinned step count)` without persisting the expansion itself. +- **Expansion-placement validation**: the validator (U1) requires that a `parse-steps` node (KTD-12) dominates — precedes on all paths — any `foreach` with `source: "task-steps"`. This prevents a silent wrong outcome where a mis-authored graph reaches the foreach before parsing, sees zero steps, and merges a task with no step work done. +- **Zero steps parsed** (after a dominating planning node) → the foreach immediately traverses its `success` edge (matches today: zero steps = no merge blocker, `task-merge.ts:202`). +- Step count is **pinned at expansion and persisted** (`pinnedStepCount` on the instance rows' run scope, KTD-6). PROMPT.md edits after expansion do not re-expand; `store.updateStep`'s auto-reinit path is bypassed for graph-owned tasks (the projection writes explicit indices). The agent gets a tool-message notice when it edits steps after expansion (implementation detail, U6). +- **Pin vs. git-reconcile on resume**: if resume-time `Task.steps[]` length differs from the persisted pin (re-parse or reconcile changed it), the run does **not** guess — it fails the foreach with an audited `pin-mismatch` outcome, instance rows are cleared, and the task follows the normal graph-failure recovery path (legacy requeue with git-reconciled `steps[]` as truth). U4 tests cover both grow and shrink. + +### KTD-4 — `step-review` node: verdicts become outcome edges + +New node kind `step-review`, config `{ type: "plan" | "code", model?: ... }`, legal only inside a foreach template. Its handler calls `reviewStep(...)` (`packages/engine/src/reviewer.ts:306`) under `semaphore.runNested` (same as today, `executor.ts:7397`) against the **current instance's** step, and maps the verdict to outcome edges: + +- `outcome:approve` → typically routes forward; the projection marks the step `done` (preserving today's APPROVE-auto-completes semantics for code reviews). +- `outcome:revise` → typically a **rework edge** back to the instance's `step-execute` node (no reset; the session revises in place — today's REVISE). +- `outcome:rethink` → a rework edge whose traversal triggers `resetStepToBaseline` first (today's RETHINK). +- `outcome:unavailable` → bounded retry (mirroring the in-session `planSpecUnavailableCounts` limiter, `executor.ts:7297`), then routes `outcome:unavailable` if still failing; the validator requires it routed or defaults it to the node's `success` path in advisory fashion (plan reviews are advisory today). + +The validator requires `approve` and `revise` to be routed; `rethink` defaults to the `revise` target with reset semantics if unrouted. Review nodes are read-only with respect to the worktree. **Verdict authority is single-writer**: only a step-review node on the instance's main path may author the verdict that routes the instance and writes the projection; step-review nodes inside `split` branches are advisory-only (validator-enforced), so fan-out checks can never clobber the authoritative verdict or race the rework budget. `step-execute` may not appear in split branches at all (validator-enforced, extending `SEAM_FORBIDDEN_IN_BRANCH`). + +### KTD-5 — Rework edges: the only legal cycles, bounded per instance + +New edge attribute `kind: "rework"`. **Mechanism**: foreach instances execute in an **iterative region sub-walk** modeled on `walkBranch` (`workflow-graph-branches.ts:240+`, a `for(;;)` loop over `currentId`), NOT through the recursive `walk`. The recursive walk's `inStack` cycle detector (`workflow-graph-executor.ts:145`) is untouched and still throws on any back-edge outside an active instance region — rework is a loop-back of `currentId` *within* the instance's iterative sub-walk, where cycles are naturally expressible. The two approaches are mutually exclusive; loosening the recursive detector is explicitly NOT the design. Rework traversal count per instance is bounded by `maxReworkCycles` (default 3, hard cap 10 — same clamp posture as `maxRetries`). Exhaustion emits `outcome:rework-exhausted` from the foreach instance; the validator requires it routed (escalation node, hold for human, or failure) or defaults to `failure`. This is deliberately distinct from per-node `maxRetries`, which stays exception-only. + +### KTD-6 — Persisted instance run-state; resume reconstructs deterministically (schema v108) + +New table `workflow_run_step_instances` (migration 108, additive): + +``` +taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt +``` + +(`branchName`/`integratedAt` and the `awaiting-integration` status serve parallel mode, KTD-11; null/unused at `concurrency: 1`.) The same v108 migration adds `tasks.customFields TEXT DEFAULT '{}'` (KTD-13) — one schema bump for the whole plan. + +- **No new interface layer**: the CRUD pair (`saveWorkflowRunStepInstance` / `loadWorkflowRunStepInstances` / `clearWorkflowRunStepInstances`) are direct store methods wired into the executor via the same additive-guard pattern as `buildBranchPersistence()` (`executor.ts:3302`). A named persistence interface gets added only if a second adapter materializes in the same PR (e.g., in-memory for tests). +- On resume: rebuild the instance set from the persisted `pinnedStepCount` + rows (mismatch with live `steps[]` → `pin-mismatch` failure, KTD-3); completed instances skip; the in-flight instance seeds its iterative sub-walk position directly from persisted `currentNodeId` + `reworkCount` — **not** from a node-id-keyed `completedNodeIds` skip set, which is unsound under rework cycles (the same node id legitimately runs multiple times). `reconcileStepsFromGitHistory` remains the git-truth fallback and its verdict wins over stale instance rows (rows are corrected to match, never the reverse). +- A stale-instance recovery sweep mirrors `recoverStaleTransitionPending()` — instances `in-progress` with no live session lease are reset to the projection's truth. +- Rows are pruned per run like `clearWorkflowRunBranches` (#1412 pattern). Archived tasks freeze the projection (steps[] persists on `ArchivedTask`); instance rows are pruned. + +### KTD-7 — `Task.steps[]` stays the physical sink (emulation, not rewrite) + +Instance lifecycle writes go **through `store.updateStep`** (`store.ts:7546`) with explicit indices: instance start → `in-progress`, approve/complete → `done`, rethink reset → `pending` (via `resetStepToBaseline`). Consequences, all intentional: + +- Merge-blocker (`task-merge.ts:202`), dashboard step bars, TUI step lists, `mesh-lease-manager` work-started detection, and lost-work reset keep reading the same array with the same semantics — **no consumer changes**. +- `updateStep`'s regression/out-of-order guards stay active. The graph writes are ordered (sequential instances), so guards should never fire; if they do, that's a projection bug surfaced loudly in the audit log (U6 adds an audit warning on guard-suppressed graph writes rather than today's silent ignore). +- `currentStep` auto-advance behavior is preserved by construction. Rework loops legitimately move a step `done→pending` only via `resetStepToBaseline` (RETHINK), which today already does exactly that — board display of a step regressing is existing behavior. +- The merge-blocker race (instance completes vs. projection flush) is closed by ordering: `updateStep` is called **before** the instance row flips to `completed` (projection-first ordering, same reservation-first discipline as capacity in plan 003 KTD-10). + +### KTD-8 — Flag posture: `workflowGraphExecutor` gates it; flag pinned per run + +The foreach/step-review machinery is interpreter functionality, gated by the existing `experimentalFeatures.workflowGraphExecutor` flag (the columns flag is orthogonal and untouched). The flag is **read once at dispatch and pinned for the run** — a mid-flight toggle takes effect on the next dispatch, never mid-walk (closes the dual-writer hazard: legacy and graph paths both mutate `steps[]`/worktree). The same pin applies to execution physics: graph-owned stepwise runs force step-session mode for the run's duration regardless of the `runStepsInNewSessions` setting (KTD-2), so the flag-interaction matrix (graph ON × step-sessions OFF) cannot select an unsupported physics combination. Flag-OFF rollback mid-task follows the existing `fell-back`/recovery posture: instance rows are swept, `steps[]` (the projection — always git-reconcilable) is the surviving truth, and legacy resume reconciles from git exactly as it does today. IR with foreach nodes is v2-only; `downgradeIrToV1IfPure` already refuses non-v1 node kinds, so #1405's rollback contract is preserved automatically. + +### KTD-9 — Built-in stepwise coding workflow is the demonstration + parity subject + +A second built-in (`builtin-stepwise-coding-workflow-ir.ts`): plan seam → parse-steps(PROMPT.md, step-headings) → foreach(task-steps){ step-execute → step-review(code) with approve→exit, revise→rework, rethink→rework+reset, rework-exhausted→hold(manual) } → review seam → merge seam. Its observable step-state trajectory for equivalent inputs must match the **legacy step-session path's** trajectory (same `updateStep` sequence, same merge-blocker windows; the step-session path is the deterministic oracle — the agent-paced monolithic path is not deterministically comparable, see U7) — asserted by a characterization-style trajectory comparison, reusing the `workflow-parity.ts` observation machinery rather than inventing new drift tracking. + +### KTD-10 — Authoring surface: node editor additions, board untouched + +`WorkflowNodeEditor` gains foreach (container/group node rendering the template subgraph), step-review, and rework-edge authoring; `workflow-flow-mapping.ts` round-trips them. The board/lanes render nothing new — step progress continues to come from `Task.steps[]` (KTD-7). Per-instance metadata (rework counts, verdicts) surfaces only in the task detail workflow results area, additively. + +### KTD-11 — Parallel step execution: per-instance worktrees, ordered integration, optimistic conflicts + +`mode` and `isolation` are explicit foreach config (KTD-3). Worktree isolation applies whenever `isolation: "worktree"` — in `sequential` mode it yields one-at-a-time instances with branch isolation + ordered integration (clean branch-scoped RETHINK without concurrency); in `parallel` mode, when the dependency graph admits it, multiple step instances run concurrently. The physics: + +- **Isolation**: each worktree-isolated instance gets its **own worktree and branch** off the current **integration base** (the task's main branch tip at instance start), allocated through the existing worktree pool (`worktree-pool.ts`) with canonical per-instance branch names. Each instance is its own step-session (`StepSessionExecutor` already creates per-step sessions — natural fit) and acquires a normal `AgentSemaphore` lease, so machine resource ceilings apply unchanged; `concurrency` is additionally clamped by available semaphore slots (parallelism degrades gracefully to sequential under contention, never deadlocks waiting for slots it holds). +- **Ordered integration**: under worktree isolation, instance completion does NOT mark the step done. A foreach-internal **integration stage** lands completed step branches onto the integration base **in step order** (rebase/cherry-pick); only successful integration flips the projection (`updateStep(...,"done")`) and unblocks dependents. Shared isolation integrates trivially — work lands directly in the main worktree, and done-at-step-completion semantics match the rest of this plan unchanged. +- **Conflicts are optimistic**: no upfront file-scope declarations. A rebase/cherry-pick conflict during integration emits `outcome:integration-conflict` from that instance — default routing is a rework edge: the instance's branch is discarded, and the step re-executes **on the updated base** (counting against its `maxReworkCycles` budget; exhaustion routes `rework-exhausted` as usual). The validator requires `integration-conflict` routed or defaults it to the rework path. +- **RETHINK under worktree isolation is branch-scoped**: `resetStepToBaseline` resets the instance's branch only — sibling instances and the integration base are untouched, which makes the KTD-2 blast-radius guard structural rather than defensive in this mode. (Shared isolation keeps the KTD-2 guard as written.) +- **Reconcile alignment**: `complete step N` commits exist on instance branches before integration and on the main history after it; `reconcileStepsFromGitHistory` reads main-worktree history, so its verdict ("done iff integrated") agrees with the projection rule by construction. +- **Projection ordering guard**: `store.updateStep`'s out-of-order-done guard (`store.ts:7592-7610`) assumes index order; graph-source writes (U6's `source: "graph"`) relax it to **dependency order** — a done write is legal when all `dependsOn` steps are done. `currentStep` auto-advance (first non-done scan) is order-agnostic already. +- **Persistence**: instance rows gain `branchName` and `integratedAt`; status adds `awaiting-integration`. Crash under worktree isolation resumes by reconciling rows against branch existence: integrated → done; branch exists, not integrated → re-enter integration queue; branch missing → instance re-runs. + +### KTD-12 — Workflow-defined task artifacts & a `parse-steps` node + +Today `PROMPT.md` is hardcoded engine law: created by the planning phase, parsed by the fixed regex in `parseStepsFromPrompt` (`store.ts:8534`), and assumed by reconcile, step init, and resume prompts. Inversion — **step parsing is itself a graph node**, visible and reorderable workflow structure: + +- **IR gains an `artifacts` declaration**: `artifacts: [{ key, title?, producedBy?: "planning" | "manual", role?: "step-source" | "context" }]`. Artifacts ride the **existing task-documents machinery** (`TaskDocument`, `fn_task_document_write/read`) — no new storage; `PROMPT.md` becomes the default workflow's declared `step-source` artifact backed by its current file location (the document layer already fronts it). +- **New node kind `parse-steps`**, config `{ artifact: , parser: "step-headings" | "json-steps" }`. Its handler reads the artifact at walk time, runs the named parser, and writes the canonical step list through the projection sink (`Task.steps[]` via the store, graph source — same single authority as everything else). Outcomes: `success`, `outcome:no-steps` (parsed cleanly, zero steps — routable, defaults to success), `outcome:parse-error` (malformed artifact — routable, defaults to failure). The node is the *only* graph-side writer of the step list; running a `parse-steps` after a foreach has already expanded trips the KTD-3 pin protection (audited failure), so re-plan loops cannot silently desynchronize an expanded foreach. +- **Parser registry is pluggable**: the registry (same posture as the trait registry) holds built-ins `step-headings` (the extracted `parseStepsFromPrompt` logic, including the `(depends: …)` annotation from U1 — extraction, not rewrite) and `json-steps` (a structured `[{name, depends?}]` document for workflows that plan in JSON), and **plugins register parsers** under namespaced ids (`plugin::`) through the plugin runtime — mirroring `plugin-trait-adapter.ts`. Contract: `(artifactContent, ctx) → {steps: [{name, dependsOn?}]}`, executed through the plugin runner with a timeout; an unavailable or throwing plugin parser maps to `outcome:parse-error` (fail-closed, audited) — never a crash. IR referencing a parser absent from the live registry is rejected at save with the id named. +- **foreach consumes, never parses**: foreach `source: "task-steps"` just reads `Task.steps[]`; the KTD-3 dominance validation is now concrete — a `parse-steps` node must dominate any `foreach(source:"task-steps")` on all paths (the monolithic default workflow has no foreach and keeps its legacy in-`execute()` step init untouched). +- **The planning seam contract**: the workflow's `producedBy: "planning"` artifacts are what the planning seam is told to produce (surfaced in the planning prompt); the engine no longer assumes PROMPT.md by name outside the default workflow's declaration. Reconcile (`reconcileStepsFromGitHistory`) and resume read through the workflow's parse-steps declaration to know which artifact/parser governs the task. +- **Parity**: the legacy path (`parseStepsFromPrompt` callers in store/executor) delegates to the same extracted `step-headings` parser function — byte-identical, proven by the existing parse tests running against both the direct call and the registry resolution. The stepwise builtin's chain becomes: plan seam → `parse-steps(PROMPT.md, step-headings)` → foreach. + +### KTD-13 — Workflow-defined custom task fields + +The task model is recast as: **core fields** (title, description) + **standard metadata** (column/status, timestamps, branch/git state, workflow selection) + **workflow-defined custom fields** for everything else. This round ships the field system; built-ins stay where they are (see Out of scope). + +- **IR gains `fields`**: `fields: [{ id, name, type, required?, default?, options?, render? }]` where `type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options: [{value, label, color?}]` for enum kinds; `render: { placement?: "card" | "detail" | "detail-section", widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle", badge?: boolean }` as rendering instructions. Validator enforces id uniqueness, type whitelist, options present iff enum-kind, render-hint whitelist. +- **Storage**: `tasks.customFields` JSON column (added in the same v108 migration as the instance table — one schema bump for the plan). Values keyed by field id. +- **Single write authority**: a store-level `updateTaskCustomFields(taskId, patch)` (and the same path inside `updateTask`) validates every write against the task's workflow field schema — type check, enum membership, required-on-transition is NOT enforced this round (fields are data, not gates; a `gate` trait can read them later). Invalid writes are typed rejections, mirroring `TransitionRejection` style. +- **Schema evolution**: editing a workflow's fields follows the reconciliation posture of columns (#1409/`rehome_to` precedent): removing a field orphans existing values (retained, rendered under an "orphaned fields" disclosure in detail UI, excluded from cards); an incompatible type change is rejected unless the update names `coerce: "drop" | "keep-orphaned"`. Tasks switching workflows keep values for ids the new workflow also defines (same id = same field by convention within a project), orphan the rest. +- **Agent-native parity**: `fn_task_update` accepts a `custom_fields` patch (validated through the same authority); `fn_workflow_create/update` accept `fields`; field values are surfaced in session/task context so agents can read and set them. + +### KTD-14 — Dynamic task UI from the field schema + +- **Task detail**: `TaskDetailModal` renders a schema-driven fields section — widget per `type`+`render.widget` (select/radio/chips for enums, toggle for boolean, date input, validated url/number inputs, textarea for text), grouped by `placement` (`detail` inline near description; `detail-section` as a collapsible group). Saves go through the dashboard route → store authority; validation errors surface inline per field (400 with field path). +- **Card front**: fields with `placement: "card"` render as badges/chips on `TaskCard` (enum colors from `options[].color`; boolean as a labeled chip when true). Card real estate is bounded: max 3 card-placed fields rendered, overflow indicated — the validator warns (not rejects) past 3. +- **Data flow**: field definitions ship with the board-workflows payload (`/api/tasks/board-workflows` already carries per-workflow data and invalidates on `workflow:updated` SSE); task field values are already on the task payload via `customFields`. +- **Workflow editor**: a **Fields panel** in the workflow editor (sibling to the column panel from the columns track) — add/edit/remove field definitions, enum option editor with color picker, render-placement controls, live badge preview. Reuses the editor's existing validation-at-save surfacing. +- **TUI**: read-only rendering of card-placed fields in the task detail view (chips → bracketed labels); no TUI editing this round. + +### KTD-15 — `code` node: arbitrary TypeScript in the graph + +A general computation escape hatch so workflows can express logic no built-in node covers (derive a field from artifacts, call an internal API, compute routing data): + +- **New node kind `code`**, config `{ source: string, timeoutMs?: number }` — inline TypeScript. Compiled in-memory with esbuild at execution (and syntax-checked at save: the validator runs the same transform and rejects IR whose source fails to compile), then run in a **child process** with cwd = the task's worktree. +- **Harness contract**: the script default-exports `async (ctx) => result`, where `ctx = { task, steps, customFields, context, artifacts: {read(key)}, instance? }` (instance present inside a foreach template — the `foreach:active` data). The returned `{ outcome?, value?, contextPatch?, customFields? }` maps to graph behavior: `outcome` routes `outcome:` edges (absent → success), `contextPatch` merges into walk context, `customFields` writes through the U11 validation authority. Throw/timeout/non-zero exit → `failure` outcome with the error audited. +- **Boundaries**: the code node does NOT get a store handle, engine internals, or the step-list write path — steps are written only by `parse-steps` (KTD-12), task fields only through the validated patch it returns. It can read artifacts and the worktree; it runs with the same trust as existing workflow script steps (workflows are project-local, user-authored config — `WorkflowStepMode "script"` already executes arbitrary project scripts today, so this adds expressiveness, not a new trust tier). Timeout clamped (default 30s, cap 300s); stdout/stderr captured and size-capped into the node result; source size capped (64KB). +- **Placement rules**: legal anywhere a script node is legal, including inside foreach templates and split branches (it is review-side/read-only with respect to git unless the author's code itself writes files in the worktree — same as script nodes today; the file-scope guard still applies to anything it commits via the session, and the code node itself never commits). +- **Editor**: palette entry + inspector with a code textarea (monospace, esbuild syntax errors surfaced at save), timeout input. + + +--- + +## Requirements + +- R1: A workflow can define a per-step template subgraph instantiated once per planned step at runtime (`foreach`, source `task-steps`). +- R2: Step execution is exposed by the substrate as the `step-execute` seam only. At `concurrency: 1` (default) instances run sequentially in step order in the task's main worktree; at `concurrency > 1` dependency-satisfied instances run concurrently in per-instance worktrees (R15). +- R3: Per-step review is a graph node whose APPROVE/REVISE/RETHINK/UNAVAILABLE verdicts route as outcome edges; validator enforces approve/revise routing and read-only placement rules. +- R4: RETHINK semantics (git reset to per-step baseline + session rewind + step→pending) are a substrate capability triggered by rework-edge traversal; baseline/checkpoint persist across restart. +- R5: Rework cycles are the only legal graph cycles, scoped to one template instance, bounded by `maxReworkCycles` (default 3, cap 10), with a routed exhaustion outcome. +- R6: `Task.steps[]` remains the projection sink via `store.updateStep`; merge-blocker, dashboard/TUI step display, reconcile, and lost-work behavior are unchanged for all tasks. +- R7: Instance run-state persists (schema v108), survives crash/restart with deterministic identity, is swept when stale, and is pruned per run; git reconcile remains authoritative over instance rows. +- R8: Zero planned steps → foreach no-ops through its success edge. +- R9: Default workflow byte-identical flag-ON/OFF (existing characterization suites stay green, unmodified in intent). +- R10: Flag pinned at dispatch; mid-flight toggles affect only subsequent dispatches; flag-OFF rollback mid-task converges via existing fell-back + git-reconcile recovery. +- R11: Built-in stepwise workflow reproduces the legacy per-step trajectory for equivalent runs (parity assertion via `workflow-parity.ts` observations). +- R12: Node editor can author/round-trip foreach, step-review, rework edges; IR validation errors surface at save time; i18n-wrapped strings; component tests registered in `qualityAppComponentTests`. +- R13: Agent tools and plugin SDK expose the new IR types (type-only in SDK); `fn_workflow_create/update` accept the new constructs with the same validation. +- R14: Five lifecycle invariants + lost-work guard trio remain non-configurable and covered by tests on the stepwise path. +- R15: Parallel step execution per KTD-11 — foreach exposes explicit `mode` (sequential|parallel) and `isolation` (shared|worktree) axes (parallel+shared validator-rejected); `dependsOn` parsed from PROMPT.md (unannotated steps depend on the previous step, preserving sequential behavior by default); per-instance worktrees off the integration base; ordered integration flips the projection (done iff integrated); rebase conflicts route `outcome:integration-conflict` to rework on the updated base within the rework budget; concurrency clamped by semaphore availability without deadlock; dependency cycles rejected at expansion. +- R16: Step parsing is a graph node per KTD-12 — `parse-steps(artifact, parser)` is the only graph-side writer of the step list, with routable `no-steps`/`parse-error` outcomes; workflows declare task artifacts; the parser registry's `step-headings` is the extracted `parseStepsFromPrompt`, byte-identical for legacy callers; a parse-steps node must dominate any foreach; parse-steps after foreach expansion trips the pin protection; plugin-registered parsers resolve through the plugin runtime and fail closed to `parse-error`. +- R17: Workflows define custom task fields (typed, enum options, render instructions) per KTD-13; values validated through a single store authority with typed rejections; field removal orphans (never destroys) values; agent tools have full read/write parity. +- R18: Task UI renders the field schema dynamically per KTD-14 — detail form widgets by type, card badges by placement, workflow-editor Fields panel; zero custom fields renders exactly today's UI. +- R19: `code` node per KTD-15 — save-time compile validation, child-process execution with clamped timeout and capped output, harness contract honored (no store handle, fields only via the validated patch, steps never), throw/timeout → audited failure. + +--- + +## Implementation Units + +### U1 — IR: foreach, step-review, rework edges, dependsOn parsing, validation + +- **Goal**: Additive WorkflowIr v2 extensions with full validation (R1, R3 validator half, R5 shape, R8 shape, R15 shape). +- **Files**: Modify `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/types.ts` (`TaskStep.dependsOn?: number[]`), `packages/core/src/store.ts` (`parseStepsFromPrompt` regex extension for `### Step N (depends: 1,2): Title` — the current regex `^###\s+Step\s+\d+[^:]*:` breaks on the colon inside the annotation, so the updated regex must parse the annotation explicitly AND remain byte-identical for unannotated headings); tests `packages/core/src/__tests__/workflow-ir.test.ts` (extend), new `packages/core/src/__tests__/workflow-ir-foreach.test.ts`, store parse tests extended. +- **Approach**: Add `foreach`, `step-review`, and `parse-steps` to `WorkflowIrNodeKind` (parse-steps config validation: artifact key references a declared artifact, parser in the registry whitelist); `WorkflowIrEdge.kind?: "rework"`; foreach `config.template` as inline `{nodes, edges}` validated recursively (single entry/exit, no nested foreach this round, `step-execute` seam legal only here, `split` branches inside templates may contain only read-only nodes — extend `SEAM_FORBIDDEN_IN_BRANCH`). Verdict-routing validation per KTD-4 including the single-writer rule (step-review inside a split is advisory-only); **dominance validation**: a steps-populating node must precede any `foreach(source:"task-steps")` on all paths (KTD-3); rework edges legal only intra-template; `mode`/`isolation`/`concurrency` validation (parallel+shared rejected; concurrency ≥1, cap 8, parallel-mode-only); `V1_NODE_KINDS` untouched so `downgradeIrToV1IfPure` refuses these (KTD-8). +- **Patterns to follow**: `validateParallelism` / `walkBranchToJoin` (`workflow-ir.ts:92-180`) for region validation; hold-node config validation (`workflow-ir.ts:214-221`). +- **Test scenarios**: parse/validate happy path; template with 0/2 entries rejected; step-execute outside foreach rejected; step-execute inside a split branch rejected; rework edge crossing template boundary rejected; unrouted approve/revise rejected; unrouted rethink defaults to revise target; unrouted rework-exhausted defaults to failure; foreach not dominated by a parse-steps node rejected; parse-steps referencing an undeclared artifact or unknown parser rejected; verdict-authoring step-review inside a split rejected (advisory-only allowed); maxReworkCycles clamp (0→1? reject; 99→10); parallel+shared isolation rejected; concurrency clamp + concurrency-on-sequential rejected; `(depends: 1,2)` parsed into dependsOn; unannotated headings parse byte-identically to today; malformed depends annotation falls back to plain-name parse; v1 round-trip refusal (`downgradeIrToV1IfPure` returns v2 unchanged); JSON round-trip stability. +- **Verification**: new + extended IR tests green; `pnpm --filter @fusion/core build`. + +### U2 — Substrate seams: `runTaskStep` / `resetStepToBaseline` + +- **Goal**: Build the two substrate capabilities per KTD-2 (R2, R4 mechanics). **Execution note: characterization-first** — capture the `StepSessionExecutor` call sequence (updateStep ordering, commit observation, reset behavior incl. missing-baseline/checkpoint partial paths) before building on it. +- **Files**: Modify `packages/engine/src/executor.ts` (`:4291-4350` step-session paths and `:7455-7505` RETHINK block); new `packages/engine/src/step-runner.ts` if extraction warrants a module; tests `packages/engine/src/__tests__/step-runner.test.ts`, extend `packages/engine/src/__tests__/executor-step-session.test.ts`. +- **Approach**: `runTaskStep` is a thin driver over `StepSessionExecutor` (graph-owned runs force step-session physics, KTD-2/KTD-8); the RETHINK block is accessor-extracted verbatim into `resetStepToBaseline` with the blast-radius guard added. Baseline captured at instance start (substrate `git rev-parse HEAD` — documented behavior change, KTD-2). The agent still authors step commits; `runTaskStep` observes them. Legacy in-session path keeps calling the same underlying code — `fn_review_step` behavior is untouched. +- **Patterns to follow**: `runImplementationPhase` extraction (`executor.ts:3444-3455`); `executor-test-helpers.ts` harness. +- **Test scenarios**: runTaskStep marks in-progress→done with correct commit message; failure outcome leaves step non-done; reset with baseline+checkpoint does git reset + rewind + pending; reset missing baseline skips git reset but still flips pending; reset missing checkpoint skips rewind; legacy fn_review_step path byte-identical (characterization). +- **Verification**: characterization tests green pre- and post-extraction; engine vitest targeted suites; `pnpm --filter @fusion/engine exec tsc --noEmit`. + +### U3 — Graph executor: expansion, instance walk, bounded rework cycles + +- **Goal**: Executor support for foreach expansion, deterministic instance identity, sequential instance execution, rework back-edges with per-instance bounds, verdict outcome edges (R1, R2 ordering, R5, R8). +- **Files**: Modify `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/workflow-node-handlers.ts`; tests new `packages/engine/src/__tests__/workflow-graph-foreach.test.ts`. +- **Approach**: On reaching a foreach node, read `Task.steps[]`, pin the count, materialize instance node ids `#:` and run each instance through an **iterative region sub-walk** modeled on `walkBranch` (KTD-5 — the recursive walk's cycle detector at `:145` is untouched); rework edges loop `currentId` back within the instance, decrementing the per-instance budget; exhaustion emits `outcome:rework-exhausted`. **Active-instance context is threaded via the existing `contextPatch` mechanism under the reserved key `foreach:active`** carrying `{foreachNodeId, stepIndex, baselineSha, checkpointId}` — handlers already read `context`, and U5/U8 depend on this key explicitly (decision promoted from Deferred; reserved-key prefix prevents collision with split/join context patches). Zero steps → success edge. Abort signal honored between nodes (existing posture). +- **Patterns to follow**: `runSplitJoin` / `walkBranch` (`workflow-graph-branches.ts:148-364`) for region sub-walks, abort, and completed-node skip; `executeNodeWithRetries` (`workflow-graph-executor.ts:244-280`) for bound clamping. +- **Test scenarios**: 3-step expansion runs 9 instance nodes in order; zero steps skips; revise rework loops twice then approves; rework exhaustion routes exhausted edge; rework budget is per-instance not shared; non-rework cycle still throws; abort mid-instance stops cleanly; outcome:unavailable retry-then-route; split fan-out of read-only checks inside a template joins correctly; `foreach:active` context key visible to template handlers and absent outside instances. +- **Verification**: foreach executor suite green; existing `workflow-graph-executor-parity.test.ts` and `workflow-graph-fanout.test.ts` untouched and green. + +### U4 — Persistence + resume + recovery (schema v108) + +- **Goal**: Instance run-state survives crash/restart; stale sweep; pruning (R7, R10 recovery half). +- **Files**: Modify `packages/core/src/db.ts` (migration 108), `packages/core/src/store.ts` (CRUD: `saveWorkflowRunStepInstance` / `loadWorkflowRunStepInstances` / `clearWorkflowRunStepInstances`), `packages/engine/src/executor.ts` (`buildStepInstancePersistence()`), `packages/engine/src/self-healing.ts` (stale sweep); tests `packages/core/src/__tests__/db-migrate.test.ts` (extend, incl. v107→108 forward path per #1417 pattern), new `packages/core/src/__tests__/workflow-step-instances.test.ts`, extend `packages/engine/src/__tests__/restart.integration.test.ts`. +- **Approach**: Mirror `workflow_run_branches` table + `WorkflowBranchPersistence` adapter + additive guard (`executor.ts:3302-3314`); resume reconstruction per KTD-6 with git-reconcile authoritative; sweep mirrors `recoverStaleTransitionPending`; prune per run. +- **Test scenarios**: migration 107→108 forward; CRUD round-trip; crash mid-instance-2 resumes at instance 2 with instances 0-1 skipped; crash during rework pass 2 resumes at pass 2 (seeded from `currentNodeId`+`reworkCount`, not re-running pass 1); steps[] grows on resume after pin → `pin-mismatch` failure; steps[] shrinks on resume → `pin-mismatch` failure; stale in-progress row with no lease swept to projection truth; git says step N done but row says in-progress → row corrected; rows pruned on run completion; pre-108 store → in-memory fallback (additive guard). +- **Verification**: core + engine suites green; schema version literal sweep done **up front, atomically with the migration commit**, using the broad pattern `grep -rn 'toBe(107)' packages/` (~40+ sites across at least 8 test files: db.test.ts alone has ~25; insight-store, goals-schema, run-audit, task-documents, store-merge-queue, merge-request-record, mission-store add more — the narrow `getSchemaVersion()).toBe(107)` pattern missed satellites last round). + +### U5 — step-review node handler + verdict wiring + +- **Goal**: Graph-native per-step review delegating to `reviewStep`, verdicts as outcomes, UNAVAILABLE limiter, rethink-triggers-reset (R3, R4 routing half). +- **Files**: Modify `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/executor.ts` (handler wiring + reset trigger on rework traversal), `packages/engine/src/reviewer.ts` (only if a narrow option needs exposing); tests new `packages/engine/src/__tests__/workflow-step-review.test.ts`. +- **Approach**: Handler resolves the active instance from the `foreach:active` context key (U3), calls `reviewStep` under `semaphore.runNested`; verdict→outcome mapping per KTD-4 with single-writer verdict authority (split-branch reviews advisory-only); persists verdict + reworkCount into the instance row (replacing the in-memory maps **for graph-owned tasks only** — legacy maps untouched); rethink rework-edge traversal invokes `resetStepToBaseline` with persisted baseline/checkpoint before re-entering the instance. +- **Patterns to follow**: `createGateHandler` / `createPromptLikeHandler` (`workflow-node-handlers.ts:43-66`); fail-closed posture of gates. +- **Test scenarios**: approve marks step done (projection) and routes approve edge; revise routes rework without reset; rethink resets (git+session+pending) then re-executes; unavailable retries then routes; verdict persisted across simulated restart; review node in a split branch runs read-only against the same instance without verdict clobbering (serialized per instance). +- **Verification**: step-review suite green; reviewer suites untouched green. + +### U6 — Projection discipline: updateStep wiring + guard audit + +- **Goal**: All instance lifecycle writes flow through `store.updateStep` with projection-first ordering; guard-suppressed graph writes audit loudly; PROMPT.md-edit-after-expansion notice (R6, KTD-7). +- **Files**: Modify `packages/engine/src/executor.ts` / `workflow-graph-executor.ts` (write ordering), `packages/core/src/store.ts` (audit warning on suppressed graph-source updateStep; explicit-index bypass of auto-reinit for graph-owned tasks); tests extend `packages/core/src/__tests__/store.test.ts` step sections, new assertions in `workflow-graph-foreach.test.ts`. +- **Approach**: `updateStep` gains an optional `source: "graph"` arg (additive, default legacy semantics); graph-source writes relax the out-of-order-done guard (`store.ts:7592-7610`) from index order to **dependency order** (done is legal when all `dependsOn` steps are done — KTD-11); projection-first ordering (updateStep before instance row flip) closes the merge-blocker race; merge-blocker, dashboard, TUI, mesh-lease, lost-work paths verified unchanged by existing tests. +- **Test scenarios**: projection-first ordering observable (merge-blocker never sees completed-instance/pending-step inversion); guard-suppressed graph write emits audit warning; legacy updateStep silent-ignore behavior unchanged; auto-reinit bypass only for graph-owned tasks; zero-step task mergeable. +- **Verification**: store + task-merge suites green; characterization suites green. + +### U7 — Built-in stepwise workflow + parity & invariant coverage + +- **Goal**: Ship the demonstration workflow; prove trajectory parity and invariant preservation (R9, R11, R14, R10 toggle tests). +- **Files**: New `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (+ registration alongside the existing builtin); tests new `packages/engine/src/__tests__/stepwise-workflow-parity.test.ts`, extend flag-toggle/crash coverage in `restart.integration.test.ts`. +- **Approach**: IR per KTD-9; parity via `workflow-parity.ts` observation comparison of the `updateStep` trajectory + merge-blocker windows against the **legacy step-session path** (`runStepsInNewSessions` ON — the deterministic per-step oracle; the agent-paced monolithic path is not deterministically comparable and stays covered by the existing default-workflow characterization suites). **Test-file ownership is explicit**: `stepwise-workflow-parity.test.ts` owns updateStep-trajectory + merge-blocker-window comparisons (scripted runs, legacy vs stepwise); the existing `workflow-graph-executor-parity.test.ts` stays focused on default-workflow byte-identity — a header comment in each file declares its parity subject. Explicit tests for flag pinned-at-dispatch, toggle-mid-flight deferred to next dispatch, flag-OFF rollback converging via git reconcile; five invariants + lost-work trio exercised on the stepwise path. +- **Test scenarios**: identical updateStep sequence legacy-step-session vs stepwise for a 3-step approve-all run; revise-then-approve trajectory parity; RETHINK trajectory parity (incl. git state, and captured-baseline == agent-equivalent baseline assertion per KTD-2); RETHINK blast-radius guard refuses when a later instance row is completed; FN-5147 terminal-until-merged on stepwise; hard-cancel mid-instance; file-scope guard fires inside step-execute; toggle mid-run does not switch paths; OFF-rollback then legacy resume completes the task. +- **Verification**: parity suite green; full default-workflow characterization suites green unmodified. + +### U8 — Node editor authoring + flow mapping + +- **Goal**: Author/round-trip foreach (group node), step-review, rework edges; save-time validation surfacing (R12). +- **Files**: Modify `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/components/workflow-flow-mapping.ts`, `packages/dashboard/app/components/WorkflowNodeEditor.css` (or sibling component CSS per the extraction convention); tests extend `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx`, `workflow-flow-mapping.test.ts`; register any new component test in `packages/dashboard/vitest.config.ts` `qualityAppComponentTests`. +- **Approach** (design decisions committed here, not deferred): + - **Template authoring is inline**: template nodes are always-visible React Flow children of the foreach group node (`parentId` set to the group), no drill-in canvas mode. `flowToIr` partitions nodes by `parentId` — children of a foreach group reassemble into that node's `config.template`; everything else stays top-level. Empty foreach groups render an empty-state hint ("drag a step-execute node here"). + - **Palette**: add `foreach` (preset `source:"task-steps"`, auto-populating one `step-execute` child so the group is never confusingly empty), `step-review`, `parse-steps` (preset artifact `PROMPT.md`, parser `step-headings`; inspector renders an artifact select over the workflow's declared artifacts and a parser select sourced from the live registry incl. plugin parsers), and `code` (inspector: monospace source textarea with save-time esbuild error surfacing + timeout input) entries to the PALETTE array (`WorkflowNodeEditor.tsx:87`). + - **Inspector fields**: `foreach` branch renders a `mode` select (sequential|parallel), an `isolation` select (shared|worktree — shared disabled when mode is parallel, matching the validator), a numeric `concurrency` input shown only in parallel mode (min 1, max 8, placeholder 2), and a numeric `maxReworkCycles` input (min 1, max 10, placeholder 3 — mirroring the `maxRetries` input pattern at `WorkflowNodeEditor.tsx:711`); `step-review` branch renders a `type` select (plan|code) and the existing `CustomModelDropdown` (optional, like the gate node's model field). + - **Edge authoring**: selecting an edge whose source is a `step-review` node shows an edge inspector with a condition dropdown (approve/revise/rethink/unavailable — stored as `outcome:` conditions, displayed as short labels) and a "rework" toggle (sets `kind:"rework"`). Rework edges render dashed in the accent color with a loop indicator. + - Validation errors from `parseWorkflowIr` surface inline at save (existing pattern); all strings `t("key","Default")`; i18n keys added across the 6 locales via the deep-merge convention (do not let the sync prune dynamic keys — prior incident). + - **Per-instance metadata display** (KTD-10): a per-step row group in the existing `WorkflowResultsTab` — step name, verdict chip per review pass, rework count badge when > 0, `rework-exhausted` rendered as a warning state. +- **Test scenarios**: round-trip IR→flow→IR stability with foreach template (children partitioned by parentId); save with unrouted approve edge shows validation error; rework edge create/delete via edge inspector; foreach/step-review inspector field editing; palette auto-populates step-execute child; i18n keys present. +- **Verification**: dashboard component shards green locally (`qualityAppComponentTests` batch); editor save produces v2 IR accepted by core parser. + +### U10 — Parallel step execution: per-instance worktrees, dependency scheduler, ordered integration + +- **Goal**: KTD-11 end to end on top of sequential foreach (R15). **Execution note: test-first** for the integration/conflict state machine. +- **Files**: Modify `packages/engine/src/workflow-graph-executor.ts` (dependency-aware instance scheduler inside foreach), `packages/engine/src/executor.ts` / `packages/engine/src/worktree-pool.ts` (per-instance worktree/branch allocation + release), new `packages/engine/src/step-integration.ts` (ordered rebase/cherry-pick integration stage + conflict detection); tests new `packages/engine/src/__tests__/workflow-step-parallel.test.ts`, extend `restart.integration.test.ts`. +- **Approach**: Two orthogonal switches from KTD-3 config: worktree isolation (per-instance branch + ordered integration — also usable in sequential mode) and parallel scheduling (runnable set = instances whose `dependsOn` are all integrated; schedule up to `min(concurrency, free semaphore slots)` concurrently). Each isolated instance runs as its own step-session in its own worktree branched from the integration base; completion enqueues `awaiting-integration`; the integration stage lands branches strictly in step order, flipping the projection (`updateStep done`, graph source) only on success; conflict discards the branch and routes `outcome:integration-conflict` (rework on updated base, budget-counted). Branch-scoped RETHINK. Worktrees released on integration/discard (pool hygiene). +- **Patterns to follow**: `worktree-pool.ts` allocation/canonical naming; `runSplitJoin` concurrency + abort wiring (`workflow-graph-branches.ts`); merger rebase/conflict handling for the integration mechanics (`merger.ts` — reuse its conflict-classification helpers, do not reimplement). +- **Test scenarios**: diamond dep graph (1 ← 2,3 ← 4) runs 2∥3 then 4; sequential+worktree runs one at a time with per-step branches and ordered integration; unannotated plan stays fully sequential at concurrency 4; conflict between parallel steps → loser reworks on updated base and succeeds; conflict rework exhaustion routes rework-exhausted; integration order is step order even when completion order inverts; crash with one branch un-integrated resumes into the integration queue; branch missing on resume re-runs the instance; semaphore starvation degrades to sequential without deadlock; dependency cycle at expansion fails audited; RETHINK resets only the instance branch; merge-blocker stays blocked until last integration (projection rule). +- **Verification**: parallel suite green; sequential foreach suites (U3) untouched green; file-scope guard + lost-work trio exercised on parallel paths. + +### U11 — Custom task fields: IR schema, storage, write authority + +- **Goal**: KTD-13 core half (R17). **Execution note: test-first** for the validation authority. +- **Files**: Modify `packages/core/src/workflow-ir-types.ts` / `workflow-ir.ts` (`fields` declaration + validation), `packages/core/src/types.ts` (`Task.customFields?: Record`, `WorkflowFieldDefinition`), `packages/core/src/db.ts` (customFields column rides the U4 v108 migration), `packages/core/src/store.ts` (`updateTaskCustomFields` authority + `updateTask` integration + orphan handling on workflow edit/switch); new `packages/core/src/task-fields.ts` (validation: type check, enum membership, render whitelist); tests new `packages/core/src/__tests__/task-fields.test.ts`, workflow-ir tests extended. +- **Approach**: per KTD-13 — single write authority with typed rejections (TransitionRejection style); field removal orphans values; incompatible type change rejected unless `coerce` named (reuses the `rehome_to` conflict-resolution pattern from workflow updates); workflow-switch keeps same-id values. +- **Patterns to follow**: `workflow-ir.ts` column validation; `plugin-gate-verdict.ts` typed-shape style; #1409 reconciliation posture. +- **Test scenarios**: each type validates/rejects correctly; enum membership enforced; multi-enum subsets; unknown field id rejected; required default applied at task create under the workflow; field removed → value orphaned not deleted; type change without coerce rejected, with coerce honored; workflow switch keeps same-id values and orphans the rest; zero-fields workflow → writes to custom_fields rejected cleanly; JSON round-trip. +- **Verification**: core suite green; v108 migration test covers the column. + +### U12 — `parse-steps` node, workflow artifacts & parser registry + +- **Goal**: KTD-12 (R16) — the `parse-steps` node handler, artifact declarations, and the parser registry. **Execution note: characterization-first** — pin `parseStepsFromPrompt` behavior before extraction. +- **Files**: Modify `packages/core/src/workflow-ir-types.ts` / `workflow-ir.ts` (`artifacts` declaration — parse-steps node-kind validation itself lands in U1), new `packages/core/src/step-parsers.ts` (registry + `step-headings` extraction + `json-steps`), `packages/core/src/store.ts` (`parseStepsFromPrompt` delegates to the registry), `packages/engine/src/workflow-node-handlers.ts` (parse-steps handler: read artifact → run parser → write step list via the graph-source projection path → outcome mapping), `packages/engine/src/plugin-parser-adapter.ts` (new — plugin parser registration through the plugin runtime, mirroring `plugin-trait-adapter.ts`; timeout + fail-closed mapping to parse-error), `packages/engine/src/executor.ts` (reconcile + planning-prompt artifact contract reads the workflow declaration; pin-protection check for parse-steps after expansion); tests new `packages/core/src/__tests__/step-parsers.test.ts`, new `packages/engine/src/__tests__/workflow-parse-steps.test.ts`, executor reconcile tests extended. +- **Approach**: per KTD-12 — the node is the only graph-side step-list writer; registry is built-in-only; foreach `source: "task-steps"` reads the projection; the default workflow keeps its legacy in-execute step init (untouched code path) while legacy `parseStepsFromPrompt` callers delegate to the same extracted parser (parity by construction); planning seam surfaces `producedBy: "planning"` artifact keys in its prompt. +- **Patterns to follow**: trait registry (`trait-registry.ts`) for the registry shape; `createGateHandler` for handler wiring; task-documents machinery for artifact backing. +- **Test scenarios**: step-headings extraction byte-identical on existing fixtures (incl. depends annotations); json-steps parses `[{name, depends}]`; parse-steps writes steps through the graph-source projection path; `outcome:no-steps` on clean-empty parse routes (defaults success); `outcome:parse-error` on malformed artifact routes (defaults failure), not crash; missing artifact → parse-error; parse-steps after foreach expansion → pin-protection audited failure; default workflow parity (direct call vs registry resolution identical); reconcile reads through the workflow's parse-steps declaration; plugin parser happy path through the runner; plugin parser timeout/throw → parse-error (fail-closed, audited); IR referencing an unregistered plugin parser rejected at save. +- **Verification**: characterization tests green pre/post extraction; core + engine suites green. + +### U13 — Dynamic task UI: field rendering + Fields panel + +- **Goal**: KTD-14 (R18). +- **Files**: Modify `packages/dashboard/app/components/TaskDetailModal.tsx` (schema-driven fields section), `packages/dashboard/app/components/TaskCard.tsx` (card-placed badges/chips), new `packages/dashboard/app/components/TaskFieldsSection.tsx` + `TaskFieldsSection.css` + `WorkflowFieldsPanel.tsx` (editor Fields panel, sibling to the column panel), `packages/dashboard/src/routes/` (field-values PATCH endpoint → store authority, 400 with field path), `packages/dashboard/src/routes/board-workflows.ts` (field defs in payload), CLI TUI task detail (read-only chips); tests new `packages/dashboard/app/components/__tests__/TaskFieldsSection.test.tsx` + `WorkflowFieldsPanel.test.tsx` (BOTH registered in `qualityAppComponentTests`), TaskCard/TaskDetailModal tests extended. +- **Approach**: per KTD-14 — widget per type/render hint; max-3 card fields with overflow indicator; enum colors from options; orphaned-fields disclosure in detail; live badge preview in the Fields panel; zero custom fields renders exactly today's UI (snapshot-guarded); all strings `t()`-wrapped, 6-locale deep-merge. +- **Test scenarios**: each widget type renders + edits + validation error inline; card badge placement honors max-3 overflow; enum color applied; orphaned values shown in disclosure, absent from card; Fields panel CRUD + option color editor + save-time IR validation surfaced; zero-fields snapshot identical; SSE workflow:updated refreshes field defs. +- **Verification**: dashboard component shards green; `qualityAppComponentTests` registration verified; TUI render test. + +### U14 — `code` node: compile, sandbox runner, harness contract + +- **Goal**: KTD-15 (R19). **Execution note: test-first** for the harness contract and failure modes. +- **Files**: Modify `packages/core/src/workflow-ir-types.ts` / `workflow-ir.ts` (`code` node kind + save-time esbuild syntax validation + source/timeout clamps), new `packages/engine/src/code-node-runner.ts` (esbuild in-memory compile + child-process execution + harness I/O + output caps), `packages/engine/src/workflow-node-handlers.ts` (handler wiring: ctx assembly incl. `foreach:active`, result mapping to outcome/contextPatch/customFields-via-U11-authority); tests new `packages/engine/src/__tests__/code-node.test.ts`, IR validation tests extended. +- **Approach**: per KTD-15 — esbuild is already in the toolchain (vitest); child process gets cwd = worktree, a minimal env, and the serialized ctx; no store handle crosses the boundary; returned customFields patch goes through `updateTaskCustomFields`; stdout/stderr captured (capped) into the node result for the audit log. +- **Patterns to follow**: script-step execution for process spawning + capture posture; `createPromptLikeHandler` for handler shape; U11 authority for field writes. +- **Test scenarios**: happy path returns value + routes success; `outcome: "foo"` routes `outcome:foo` edge; contextPatch merges; customFields patch validated (invalid → node failure with typed rejection surfaced); syntax error rejected at IR save; runtime throw → failure audited; timeout kills the child and fails; output cap enforced; source size cap; inside a foreach template receives `instance`; no steps-write path exists (attempting one is not expressible via the contract). +- **Verification**: code-node suite green; validator round-trip green. + +### U9 — Agent tools, plugin SDK, docs, changeset + +- **Goal**: Agent-native parity and documentation (R13, R16/R17 agent halves). +- **Files**: Modify `packages/engine/src/agent-tools.ts` (`fn_workflow_create/update` accept `fields`/`artifacts`; `fn_task_update` accepts `custom_fields` patch through the U11 authority; planning-prompt guidance for `depends:` annotations; `fn_trait_list` untouched), `packages/cli/skill/fusion/references/engine-tools.md` (tool table — the skill-sync test enforces this), `packages/plugin-sdk/src/index.ts` (TYPE-ONLY re-exports of new IR types — runtime exports break the standalone-artifact test), `docs/workflow-steps.md`, `docs/architecture.md` §9 extension, `CONCEPTS.md`; new `.changeset/step-inversion-workflow-modelable-steps.md` (with rollback note, mirroring the columns changeset). +- **Test scenarios**: fn_workflow_create with a foreach IR + fields validates and persists; invalid template/field rejected with the IR error surfaced; fn_task_update custom_fields validated through the authority; SDK type-only export keeps standalone-artifact test green; skill-sync test green. +- **Verification**: agent-tools tests green; plugin-sdk test green; docs render. + +### Dependencies & sequencing + +U1 → (U2 ∥ U4-core-half) → U3 → U5 → U6 → U10 → U7; U9 last. U2, U3, U5, U6, U10 are **strictly serial** (all touch `executor.ts`/graph executor). U4's core-only half (db.ts migration + store CRUD) parallelizes with U2; U4's executor wiring (`buildStepInstancePersistence`) waits for U3. U10 (parallel/isolated execution) builds on sequential foreach (U3) + projection discipline (U6) and lands before U7 so the parity/invariant suite covers all modes. U8 may start IR-type authoring, palette, inspector, and CSS work in parallel with U3–U6 (the `foreach:active` context decision is now committed in U3's approach, so no mid-stream rewrite risk); its round-trip tests land after U1 is merged. + +The task-shape track is largely independent of the step-execution track: **U11 (fields core) parallelizes with U2–U6** (core-only, no executor surface beyond the U4 migration it shares); **U12 (artifacts/parsers) follows U1** (shares IR validation) and its executor touches (reconcile/planning contract) slot between U6 and U10 in the serial executor chain; **U13 (dynamic UI) follows U11** and parallelizes with U8 (different dashboard surfaces; both register component tests). U7's parity scope includes U12's default-workflow declaration. **U14 (code node) follows U1** (node kind) and **U11** (field-write authority); its engine half is independent of the U2–U10 executor chain except the handler-registration touch, which slots anywhere after U3. + +--- + +## Risks & Mitigations + +- **RETHINK convergence (highest risk)**: git reset + session rewind + projection + instance rows must converge. Mitigation: substrate owns the whole reset atomically (KTD-2); baseline/checkpoint persisted (KTD-6); rework traversal is the single trigger point (U5); trajectory parity test for RETHINK specifically (U7). +- **Cycle-support regression in the executor**: loosening the cycle detector could mask authored-graph bugs. Mitigation: exemption is narrowly scoped (rework kind + same active instance), non-rework cycles still throw (U3 test). +- **Dual-writer on flag transition**: closed by pin-at-dispatch (KTD-8) + OFF-rollback convergence tests (U7). +- **`executor.ts` merge conflicts with main**: extraction in a ~3k-line function while main moves (see `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md`). Mitigation: extraction commits kept mechanical and early; rebase before U5+. +- **Projection guard masking**: `updateStep` silently ignores invalid transitions, which would hide projection bugs. Mitigation: audit-loud for graph-source writes (U6). +- **Schema-version literal sweep**: v107 bump missed satellite test files last round (4 CI rounds). Mitigation: explicit grep sweep in U4's verification. +- **Integration-conflict churn (parallel mode)**: heavily-overlapping steps marked independent would loop integrate→conflict→rework until budget exhaustion. Mitigation: optimistic conflicts are budget-counted with a routed exhaustion outcome; unannotated steps default to sequential dependence, so parallelism only exists where the planner asserted it; the planning prompt guidance (U9 docs) tells planners to annotate `depends:` conservatively. +- **Worktree pool pressure**: per-instance worktrees multiply pool usage. Mitigation: concurrency cap 8, semaphore clamp, and release-on-integration hygiene in U10; pool reuse machinery already exists for fan-out branches. +- **Parser extraction parity (KTD-12)**: `parseStepsFromPrompt` has subtle behaviors (auto-reinit interplay, regex edge cases) that a registry indirection could perturb. Mitigation: characterization-first in U12; default workflow resolves to the same extracted function, asserted identical on existing fixtures. +- **Dynamic UI regression surface (KTD-14)**: schema-driven rendering touches TaskCard/TaskDetailModal, the two highest-traffic components. Mitigation: zero-fields snapshot guard (today's UI byte-identical when no fields are defined); new rendering isolated in `TaskFieldsSection`; card overflow bounded at 3. +- **Code-node misuse surface (KTD-15)**: arbitrary TS in workflows can do anything the user can. Mitigation: explicit trust framing (same tier as existing script steps), no store handle across the boundary, field writes only through the validated patch, steps never writable, clamped timeout + output caps, save-time compile rejection, full stdout/stderr audit capture. +- **Plugin parser availability drift (KTD-12)**: a workflow saved against a plugin parser can outlive the plugin. Mitigation: save-time registry check; at runtime fail-closed to `parse-error` (routable), never a crash; audit names the missing parser id. +- **Field-schema drift vs stored values**: workflow edits can strand values. Mitigation: orphan-not-delete posture (KTD-13), `coerce` confirmation on incompatible type changes, orphaned-fields disclosure in detail UI. + +## System-Wide Impact + +- `Task.steps[]` consumers: none change (KTD-7) — verified, not assumed, by existing suites in U6/U7. +- `workflow_run_branches` pattern reused, not modified; `workflow_run_step_instances` is net-new. +- Legacy in-session step path: untouched code paths; only extraction-level refactor (U2) with characterization proof. +- The columns-track flag (`workflowColumns`) and its graduation report are untouched; this track rides `workflowGraphExecutor`. + +## Deferred to Implementation + +- Whether `runTaskStep` needs a distinct module (`step-runner.ts`) or stays an executor method — decide by extraction size in U2. +- Whether the foreach instance sub-walk reuses `runSplitJoin`'s AbortController/semaphore wiring for read-only split fan-out inside templates, or a separate path — decide in U3 (split-inside-sequential-loop is untested territory in `workflow-graph-branches.ts`). +- Tool-message wording for PROMPT.md-edited-after-expansion notice (U6). +- Display-label strings for `outcome:` edge conditions in the editor (U8 renders short labels; exact i18n copy at implementation). + +## References + +- Predecessors: `docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md` (completed), `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` (superseded; its executor characterization findings remain accurate), `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md` (completed). +- Contracts: `docs/architecture.md` §9 (substrate/policy framing); `docs/rfcs/FN-5719-decouple-executor-merger.md` (lifecycle seam + invariant bar). +- Key code anchors: `packages/core/src/store.ts:7546` (updateStep), `packages/core/src/task-merge.ts:202` (merge-blocker), `packages/engine/src/executor.ts:3444` (runImplementationPhase), `:7300-7505` (fn_review_step/RETHINK), `:11067` (reconcile), `packages/engine/src/workflow-graph-executor.ts:145` (cycle detector), `:244-280` (retries), `packages/engine/src/workflow-graph-branches.ts` (region walk + persistence pattern), `packages/engine/src/reviewer.ts:306` (reviewStep), `packages/core/src/workflow-ir.ts` (validators). diff --git a/docs/residual-review-findings/gsxdsm-custom-columns.md b/docs/residual-review-findings/gsxdsm-custom-columns.md new file mode 100644 index 0000000000..9076e776ee --- /dev/null +++ b/docs/residual-review-findings/gsxdsm-custom-columns.md @@ -0,0 +1,32 @@ +# Residual Review Findings — `gsxdsm/custom-columns` + +Source: `ce-code-review mode:autofix` run `20260604-021808-25e15199` (13 reviewers + 9 validators) against merge-base `962b97ce8`, plan `docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md`. 14 safe fixes were applied and committed (`30e2fd7b0`); the findings below were filed as tracked issues and have ALL been fixed on this branch (commits 571768f03..afc72b6bb); the issues close when PR #1418 merges (Fixes references in the PR body). + +## Residual Review Findings + +- [P1] `packages/core/src/transition-pending.ts:39` — Implement transitionPending recovery sweep (markers leak capacity slots after crash) — [#1401](https://github.com/Runfusion/Fusion/issues/1401) +- [P1] `packages/engine/src/hold-release.ts:98` — Consolidate 4 copies of the workflow-IR resolution rule — [#1402](https://github.com/Runfusion/Fusion/issues/1402) +- [P1] `packages/core/src/store.ts:5697` — Widen moveTaskInternal/Task.column to ColumnId (type-honesty refactor) — [#1403](https://github.com/Runfusion/Fusion/issues/1403) +- [P1] `packages/dashboard/src/routes/register-task-workflow-routes.ts:1414` — Add route-level tests for POST /tasks/:id/promote — [#1404](https://github.com/Runfusion/Fusion/issues/1404) +- [P1] `packages/core/src/workflow-ir.ts:245` — WorkflowIr v2 persistence breaks rollback to pre-v2 binaries — [#1405](https://github.com/Runfusion/Fusion/issues/1405) +- [P1] `packages/dashboard/src/routes/register-workflow-routes.ts` — Emit workflow:updated SSE event; invalidate boardWorkflows on workflow edits — [#1406](https://github.com/Runfusion/Fusion/issues/1406) +- [P1] `packages/engine/src/workflow-graph-task-runner.ts` — Wire SQLite branch persistence for fan-out runs in production — [#1407](https://github.com/Runfusion/Fusion/issues/1407) +- [P1] `packages/engine/src/agent-tools.ts` — Agent-native parity: fn_task_promote, reconcile-aware fn_workflow_select, workflow CRUD, trait catalog — [#1408](https://github.com/Runfusion/Fusion/issues/1408) +- [P1] `packages/core/src/store.ts` — Define flag ON→OFF evacuation policy for cards in custom columns — [#1409](https://github.com/Runfusion/Fusion/issues/1409) +- [P2] `packages/dashboard/app/components/Column.tsx:193` — Clear inline capacity feedback when column tasks change via SSE — [#1410](https://github.com/Runfusion/Fusion/issues/1410) +- [P2] `packages/engine/src/self-healing.ts` — Recovery moves on custom workflows can be rejected by order-derived adjacency (use `recoveryRehome`) — [#1411](https://github.com/Runfusion/Fusion/issues/1411) +- [P2] `packages/core/src/db.ts:581` — Prune workflow_run_branches per run (unbounded growth) — [#1412](https://github.com/Runfusion/Fusion/issues/1412) +- [P2] `packages/core/src/store.ts:5039` — Filter branch-progress JOIN to the latest run in SQL — [#1413](https://github.com/Runfusion/Fusion/issues/1413) +- [P2] `packages/dashboard/src/routes/register-task-workflow-routes.ts:816` — Add HTTP integration test for GET /tasks/board-workflows — [#1414](https://github.com/Runfusion/Fusion/issues/1414) +- [P2] `packages/engine/src/hold-release.ts` — Add concurrent-sweep safety test (overlapping runHoldReleaseSweep) — [#1415](https://github.com/Runfusion/Fusion/issues/1415) +- [P2] `packages/dashboard/app/components/Board.tsx:354` — Test canDropTask pre-check branches at Board level — [#1416](https://github.com/Runfusion/Fusion/issues/1416) +- [P3] `packages/core/src/__tests__/db-migrate.test.ts` — Add v105→106/107 forward-path migration tests — [#1417](https://github.com/Runfusion/Fusion/issues/1417) + +## Advisory (report-only, no ticket) + +- [P2] `packages/core/src/store.ts` — store.ts grew ~1k lines; extract the flag-OFF legacy effects block for symmetry (maintainability) +- [P2] `packages/core/src/db.ts:4237` — applyMigration is non-transactional; the ALTER TABLE justification comment doesn't apply to DDL-only migration 107 (data-migration) +- [P2] `packages/core/src/store.ts:12192` — integrity pass silently re-homes; add a WARNING log or dry-run preview (data-migration) +- [P3] `packages/engine/src/hold-release.ts:304` — sweep countPending pre-check branch is dead (Task lacks transitionPending); authoritative in-txn count unaffected (correctness) +- [P3] `packages/core/src/store.ts:5771` — getSettingsFast awaited per move; consider a short-TTL settings cache (performance) +- Note: blocking *plugin* gate verdicts currently always reject (fail-closed) — `recordPluginGateVerdict` has no production caller yet; wire it when plugin gates ship end-to-end (security) diff --git a/docs/residual-review-findings/gsxdsm-step-inversion.md b/docs/residual-review-findings/gsxdsm-step-inversion.md new file mode 100644 index 0000000000..61e0cf2fb6 --- /dev/null +++ b/docs/residual-review-findings/gsxdsm-step-inversion.md @@ -0,0 +1,17 @@ +# Residual Review Findings — `gsxdsm/step-inversion` + +Source: `ce-code-review mode:autofix` run `20260604-132117-b1269bcd` (12 reviewers) against merge-base `d0b5dcbf`, plan `docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md`. 17 findings were fixed and committed (`3ebaa321f`); the items below remain as tracked residual work. + +## Residual Review Findings + +- [P1] `packages/engine/src/executor.ts:3945` — **Per-instance worktree isolation is commit-cosmetic**: the memoized single implementation pass runs in the MAIN worktree; instance branches receive no per-step commits, so parallel-mode integration rebases empty branches. Requires a per-step `StepSessionExecutor` scoped to `active.worktreePath`. Bookkeeping (rows/worktrees/budgets/ordering) is correct and tested; write-isolation is not yet real. Flag-gated experimental path only. +- [P2] `packages/engine/src/self-healing.ts` — Stale step-instance self-healing sweep (the `recoverStaleTransitionPending` analogue) not implemented; in-progress rows on never-re-dispatched tasks orphan forever (per-run resume seeding exists). +- [P2] `packages/engine/src/plugin-parser-adapter.ts:105` — Plugin parser timeout is post-call, not pre-emptive; a runaway synchronous parser blocks the event loop. +- [P2] `packages/engine/src/workflow-graph-foreach.ts:605` — Integration-conflict retries and reviewer rework share one `maxReworkCycles` budget; repeated conflicts can exhaust it before any REVISE runs. +- [P2] `packages/engine/src/__tests__/stepwise-workflow-parity.test.ts` — Parity oracle is a hand-written legacy simulator, not the real `StepSessionExecutor`; fidelity should be cross-checked against the step-session characterization suite. +- [P2] Test gaps: pin-mismatch grow/shrink on resume; plugin-parser timeout path; explicit `outcome:integration-conflict` edge override; `step-review type:"plan"` via the graph handler; code-node child env-restriction regression; TUI field-chip render. +- [P3] `packages/engine/src/code-node-runner.ts:316` — Temp-dir sweep for parent-crash leftovers (`fusion-code-node-*`). +- [P3] `packages/engine/src/code-node-runner.ts:245` — Compile cache keyed on source hash (N× esbuild spawns per foreach). +- [P3] `packages/engine/src/executor.ts` — Store-capability `as unknown as` casts → optional-capability interface. +- [P3] `packages/core/src/index.ts:127` — `__resetStepParserRegistryForTests` exported on the public barrel; consider a test-support entry point. +- [P3] Agent/dashboard read surface for step-instance state (rework counts, verdicts) — parity debt for when the dashboard surfaces it. diff --git a/docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md b/docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md new file mode 100644 index 0000000000..271b5441e5 --- /dev/null +++ b/docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md @@ -0,0 +1,56 @@ +--- +module: dashboard-testing +date: "2026-06-04" +problem_type: developer_experience +title: "Browser-testing the Fusion dashboard from a worktree safely (no engine, fresh bundle, free port)" +applies_when: "Running browser/E2E verification of dashboard changes from a linked worktree during a pipeline (ce-test-browser or manual agent-browser sessions)" +tags: + - "browser-testing" + - "fn-dashboard" + - "worktree" + - "stale-dist" + - "port-4040" + - "fusion-daemon" +--- + +# Browser-testing the Fusion dashboard from a worktree safely + +## Context + +During the step-inversion pipeline (PR #1424), browser verification of dashboard changes from a linked worktree hit three traps in sequence, two of them dangerous: + +1. **`fn daemon --paused` still executed real tasks.** The daemon shares the user's central DB; within ~60s it spun up executor sessions on live tasks (racing the user's main instance — a dual-engine hazard) despite `--paused`. The `--paused` flag does not prevent engine dispatch in this path. +2. **`fn dashboard --dev` (no `--port`) bound the reserved port 4040** — the default in `packages/cli/src/bin.ts` is 4040, which is reserved for the user's own dashboard (see `dev-server-port-detect.ts: RESERVED_DASHBOARD_PORT`). +3. **The served UI bundle was stale.** `fn dashboard` serves `packages/cli/dist/client` (the CLI's own copy of the dashboard build), NOT `packages/dashboard/dist/client`. Rebuilding `@fusion/dashboard` does not update the CLI copy — newly added React Flow node types rendered as `react-flow__node-default` and foreach template children were missing from the DOM entirely, which looked exactly like a source bug (it wasn't). + +## Guidance + +The safe recipe for worktree browser testing: + +```bash +pnpm --filter @fusion/core --filter @fusion/engine --filter @fusion/dashboard \ + --filter "@fusion-plugin-examples/*" build + +FUSION_ALLOW_NESTED_PROJECT=1 FUSION_SKIP_ONBOARDING=1 \ +FUSION_CLIENT_DIR=$PWD/packages/dashboard/dist/client \ +node packages/cli/bin.mjs dashboard --dev --port 4101 --token cetest123 & +# open: http://localhost:4101/?token=cetest123 +``` + +- **`fn dashboard --dev`** = web UI only, AI engine disabled. Never use `fn daemon`/`fn serve` for UI verification — they run the engine against the shared central DB. +- **Always pass `--port `** (anything but 4040). The dashboard subcommand's default is the reserved 4040. +- **`FUSION_CLIENT_DIR` pointed at the fresh `packages/dashboard/dist/client`** beats the CLI's stale `packages/cli/dist/client` copy. Without it, UI changes silently don't appear. +- Killing your own spawned test server is fine; the no-kill rule protects the user's live instance on 4040. +- If the canvas "renders nothing": check the served bundle hash before debugging source — `agent-browser eval` on `document.querySelectorAll('.react-flow__node')` distinguishes "nodes absent" from "nodes mis-typed" (`react-flow__node-default` = nodeType not registered in the served bundle). + +## Why This Matters + +The daemon trap is a data hazard, not just wasted time: two engines on one SQLite central DB race task leases and can strand tasks in limbo. The stale-bundle trap costs hours because it perfectly mimics a source-level rendering bug — the jsdom tests pass (they test source) while the browser shows old behavior (it serves dist). + +## When to Apply + +Any time a pipeline or agent verifies dashboard behavior in a real browser from a worktree: ce-test-browser runs, manual agent-browser sessions, screenshot verification of editor/board changes. + +## Examples + +Diagnosing the stale bundle (PR #1424): source `WorkflowNodeTypes.tsx` registered `foreach`, jsdom tests green, but live DOM showed `react-flow__node-default` for the foreach node and no `steps::*` children. `grep nodeTypes` in the served `WorkflowNodeEditor-*.js` chunk showed the registry ending at `join` — a pre-U8 bundle from `packages/cli/dist/client`. diff --git a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md new file mode 100644 index 0000000000..7c8ba38f33 --- /dev/null +++ b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md @@ -0,0 +1,92 @@ +--- +title: Bundled plugins must be registered in 4 independent places — they drift +date: 2026-06-04 +category: integration-issues +module: plugins +problem_type: integration_issue +component: tooling +symptoms: + - "Installing a built-in plugin from Settings → Built-in Plugins fails with \"Plugin manifest not found. Looked for manifest.json in: ...\"" + - "Plugin shows in the Settings UI but the install POST returns 404" + - "Packaged (npm/binary) installs report missing-bundle for a plugin that works in dev" +root_cause: incomplete_setup +resolution_type: code_fix +severity: medium +last_updated: 2026-06-05 +tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift, entry-file, fs-mock] +--- + +# Bundled plugins must be registered in 4 independent places — they drift + +## Problem + +Adding a bundled (built-in) plugin to Fusion requires registration in **four independently maintained lists** with no cross-check. `fusion-plugin-compound-engineering` was added to only 2 of 4, so installing it from Settings → Built-in Plugins failed with "Plugin manifest not found" (fixed in PR #1423). + +The four registration points: + +1. **Dashboard UI** — `BUILTIN_PLUGINS` in `packages/dashboard/app/components/PluginManager.tsx` (makes the card appear in Settings) +2. **Dashboard server** — `BUNDLED_PLUGIN_IDS` in `packages/dashboard/src/routes.ts` (lets the install route fall back to the bundled copy when the relative `./plugins/...` path misses the server cwd) +3. **CLI startup** — `BUNDLED_PLUGIN_IDS` in `packages/cli/src/plugins/bundled-plugin-install.ts` (auto-install/upgrade of bundled plugins) +4. **Build staging** — `packages/cli/tsup.config.ts` (`bundlePluginEntry` or a copy block staging the plugin into `dist/plugins//` so packaged installs have a copy at all) + +A plugin with a dashboard view additionally needs client-side view registration in `packages/dashboard/app/plugins/registerBundledPluginViews.ts`. + +## Symptoms + +- Settings shows the plugin card, but clicking install errors with `Plugin manifest not found. Looked for manifest.json in: /plugins/` — the cwd-relative path missed and the bundled fallback was skipped because the id wasn't in routes.ts's `BUNDLED_PLUGIN_IDS`. +- A sibling plugin added in the same commit (roadmap) installs fine — it was in all four lists. +- In packaged installs, `ensureBundledPluginInstalled` logs/returns `missing-bundle` because tsup never staged the plugin into `dist/plugins/`. + +## What Didn't Work + +- Assuming the UI list + CLI list were sufficient — the dashboard server keeps its **own** copy of the bundled-id set, and the install route's fallback silently returns null for unknown ids. +- The existing bundled-fallback route tests appeared to cover this, but their mocks let cwd resolution succeed (mock matched any path containing the plugin id), so the fallback branch was never actually exercised. + +## Solution + +Register the plugin in all four places. For the missing two: + +```ts +// packages/dashboard/src/routes.ts +const BUNDLED_PLUGIN_IDS = new Set([ + // ... + "fusion-plugin-cli-printing-press", + "fusion-plugin-compound-engineering", +]); +``` + +```ts +// packages/cli/tsup.config.ts (onSuccess) +await bundlePluginEntry({ + pluginId: "fusion-plugin-compound-engineering", + srcDir: compoundEngineeringPluginSrc, + destDir: compoundEngineeringPluginDest, +}); +``` + +## Follow-up failure: directory registered as plugin path + +Fixing the fallback surfaced a second, independent bug (fixed in PR #1428): both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: `. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: both install routes now resolve and register the entry file (helper added to `@fusion/core`; 400 with "no loadable entry file" when none exists), and **both** enable routes heal legacy directory-path rows in place before `loadPlugin` — mirroring the CLI's startup heal — so pre-fix broken registrations self-repair on first enable without a migration. + +### Trap: vitest fs mocks don't reach externalized workspace deps + +Moving `resolvePluginEntryPath` to `@fusion/core` and re-exporting from the CLI broke the CLI's tests: `vi.mock("node:fs")` in the CLI package does **not** intercept fs calls made inside the externalized `@fusion/core` import (vitest only inlines/mocks modules in the test package's transform graph — the dashboard package inlines core, the CLI doesn't). Resolution: the CLI keeps an intentionally duplicated local copy (its fs mocks work against it), both copies carry keep-in-sync comments, and a **real-fs drift-guard test** (`packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts`) imports both copies and asserts identical resolution across real temp-dir layouts — each candidate alone, precedence pairs, all three, and the no-entry → `null` case. Real directories are the only seam that exercises both implementations equally; a candidate-list change applied to one copy but not the other now fails CI. + +## Why This Works + +The Settings card sends a relative `./plugins/` path. The server resolves it against `process.cwd()` — normally the user's project dir, not the Fusion repo — so it 404s and falls back to `extractBundledPluginId()`, which only recognizes ids in routes.ts's `BUNDLED_PLUGIN_IDS`. Adding the id makes the fallback resolve the staged bundled copy; the tsup staging block guarantees that copy exists in packaged installs. + +## Prevention + +- **When adding a bundled plugin, grep for an existing one** (e.g. `rg -l "fusion-plugin-roadmap" packages/` ) and mirror every hit — that surfaces all four lists plus view registration. +- Route tests must force the fallback: mock fs so the cwd-relative path **misses** and only `dist/plugins/` exists (see "installs bundled compound engineering plugin when relative path misses cwd" in `packages/dashboard/src/__tests__/plugin-routes.test.ts`). A mock that matches any path containing the plugin id tests nothing. +- **Pin assertions to the exact contract, not substring containment.** `stringContaining(pluginId)` passed for both the correct entry-file path and the buggy directory path — when a mock or matcher can satisfy both the correct and the buggy value, the test proves nothing. Route tests now assert the registered path ends in an entry-file suffix, cover the `dist/index.js` and `src/index.ts` fallbacks, and the 400 no-entry branch. +- When duplicating a helper is forced by test infrastructure (fs mocks vs externalized deps), add a real-fs drift-guard test that runs every copy against the same on-disk fixtures and asserts identical output. +- Consider a future consistency test asserting every `BUILTIN_PLUGINS` UI entry with a `path` is present in both server-side `BUNDLED_PLUGIN_IDS` sets. + +## Related Issues + +- PR #1423 — the registration-drift fix +- PR #1428 — the entry-file/heal follow-up fix +- Issue #1096 — same Settings-install bundled-plugin failure family (missing-bundle symptom for the Paperclip runtime in global npm installs); different root cause +- Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations) diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 33f5614942..b093b0723f 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -57,6 +57,58 @@ Current reconciliation in v1: FN-5769 evaluated whether those conventions required a `1.1.0` schema bump and recorded the answer as **no**: the current `prompt` + `config` and canonical `edge.condition` token conventions are sufficient for the parity-critical interpreter rollout, so they remain the canonical v1 contract until a future consumer needs stronger schema-level validation or discoverability. +### Workflow IR v2 — columns, traits, hold & split/join nodes + +The `workflowColumns` track introduces **IR v2** (`version: "v2"`), where a workflow additionally defines its own **columns** (`{ id, name, traits: [{ trait, config }] }`), places nodes in columns (`node.column`), and gains `hold`, `split`, and `join` node kinds. Columns become first-class, workflow-defined task state carrying composable **traits** (declarative flags + lifecycle hooks); this generalizes the fixed pipeline + the `gateMode` semantics documented below into per-column trait configuration. v1 graphs still parse and upgrade by synthesizing default-workflow columns. The column/trait model — the trait vocabulary, the substrate/policy line, the transition authority, and the graduation gate — is documented in **`docs/architecture.md` § 9 "Workflow-defined columns & traits"** and the **Concepts** glossary (column, trait, lane, hold node, split/join, default workflow, `transitionPending`). The whole v2 model is gated behind `experimentalFeatures.workflowColumns`; with the flag off, the v1 IR and the quality-gate `WorkflowStep` model below are unchanged. + +### Workflow IR v2 — step inversion (foreach, step-review, parse-steps, code) + +The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**. + +#### `parse-steps` node — step list as graph structure + +`parse-steps` reads a declared **artifact** and runs a named **parser** to write the canonical step list (`Task.steps[]`). Config: `{ artifact: , parser: "step-headings" | "json-steps" | "plugin::" }`. + +- Built-in parsers: `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex) and `json-steps` (a `[{ name, depends? }]` JSON document). Plugins register additional parsers under `plugin::`. +- Outcomes: `success`, `outcome:no-steps` (parsed cleanly, zero steps — routable, defaults to success), `outcome:parse-error` (malformed artifact or a throwing/unavailable plugin parser — fail-closed, routable, defaults to failure). A plugin parser never crashes the run. +- It is the **only** graph-side writer of the step list, and **must dominate** (precede on all paths) any `foreach(source:"task-steps")` — a validator rule that prevents merging a task that reached the foreach before steps were parsed. + +#### `foreach` node — a per-step template region + +`foreach` instantiates an inline template subgraph once per planned step. Config: + +``` +{ source: "task-steps", template: { nodes, edges }, + mode?: "sequential" | "parallel", // default sequential + isolation?: "shared" | "worktree", // default: shared (sequential), worktree (parallel) + concurrency?: number, // parallel only, 1..8, default 2 + maxReworkCycles?: number } // default 3, cap 10 +``` + +- The template has exactly one entry and one exit. A `step-execute` seam node is legal **only** inside a foreach template; `step-execute` may not appear in `split` branches. +- Expansion happens when the walk reaches the node; the step count is **pinned** at expansion and persisted (PROMPT.md edits afterward do not re-expand — a `pin-mismatch` failure surfaces if the live step list later disagrees on resume). +- Zero steps → the foreach traverses its `success` edge immediately (no merge blocker, matching today). + +#### Parallel mode & the `(depends:)` annotation + +`mode` and `isolation` are independent axes. `parallel + shared` is rejected (concurrent writers in one worktree are unguardable). Under `worktree` isolation each instance runs in its own worktree/branch off a common base, with an **ordered integration stage** that lands step branches in step order (done iff integrated); a rebase conflict routes `outcome:integration-conflict` (default: rework on the updated base, budget-counted). + +Parallelism is opt-in *per step by the planner*, not asserted by the workflow author. A step depends on the previous step unless its PROMPT.md heading carries a `(depends: N,M)` annotation listing the 1-indexed steps it actually depends on — e.g. `### Step 3 (depends: 1): Title`. An unannotated plan is fully sequential regardless of `mode`. Annotate **conservatively**: only mark a step independent when it genuinely does not read or modify the prior step's output, or heavily-overlapping "independent" steps will loop integrate→conflict→rework until the budget exhausts. + +#### `step-review` node & rework edges + +`step-review` (`{ type: "plan" | "code", model? }`, legal only inside a foreach template) runs the reviewer against the current instance's step and maps the verdict to outcome edges: `outcome:approve` (marks the step done), `outcome:revise` (typically a rework edge — revise in place, no reset), `outcome:rethink` (a rework edge whose traversal first triggers reset-to-baseline: git reset + session rewind + step→pending), `outcome:unavailable` (bounded retry then route). The validator requires `approve` and `revise` routed; `rethink` defaults to the revise target with reset semantics. Verdict authority is single-writer — review nodes inside `split` branches are advisory-only. + +`rework` edges (`edge.kind: "rework"`) are the **only legal cycles**: a loop-back within one foreach instance, bounded by `maxReworkCycles`. Exhaustion emits `outcome:rework-exhausted` (validator requires it routed — escalation, hold, or failure; defaults to failure). Non-rework cycles still throw. + +#### `code` node — sandboxed TypeScript + +`code` (`{ source, timeoutMs? }`, default 30s, cap 300s) runs inline TypeScript (compiled with esbuild, executed in a timeout-bounded child process with cwd = the task worktree) for logic no built-in node covers. The script default-exports `async (ctx) => result` where `ctx = { task, steps, customFields, context, artifacts: { read(key) }, instance? }` (`instance` present inside a foreach template). The returned `{ outcome?, value?, contextPatch?, customFields? }` routes `outcome:` edges, merges `contextPatch` into walk context, and writes `customFields` through the validated field authority. It gets **no store handle**, cannot write the step list, and a throw/timeout/non-zero exit becomes an audited `failure`. Source compile errors are rejected at save time (a dashboard 400 listing the failing node ids). It runs at the same trust tier as existing project-local script steps. + +#### Workflow-defined custom task fields + +Workflows declare typed task fields via IR `fields: [{ id, name, type, required?, default?, options?, render? }]` (`type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options` for enum kinds; `render.placement ∈ card | detail | detail-section`, `render.widget`, `render.badge`). Values live in `tasks.customFields` and are validated through a single store authority (`updateTaskCustomFields`) with typed rejections (offending `fieldId` + `code`). Editing or switching a workflow **orphans** (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via `fn_task_update`'s `custom_fields` patch; authors set them via `fn_workflow_create/update`. Field values are surfaced in task/session context. + ## What They Are A workflow step is a reusable check (AI prompt or script) that can be enabled on tasks. diff --git a/eslint.config.mjs b/eslint.config.mjs index e6de615985..2b706159d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -103,6 +103,8 @@ export default tseslint.config( // Vitest temporary workspace resolution directories ".tmp-fn-*/**", ".claude/**", + // Generated i18n resource typings (emitted by i18n:types) + "packages/i18n/src/resources.d.ts", // Lock files "*.lock", "pnpm-lock.yaml", diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 49e4cfe492..79e3dae523 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -16,7 +16,13 @@ These tools are **not** part of the user-invokable extension surface. They are i | `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) | | `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) | | `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none | +| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) | | `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) | +| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) | +| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) | +| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) | +| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) | +| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none | | `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) | | `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) | | `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) | @@ -53,7 +59,7 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi | Tool | Purpose | Parameters | |---|---|---| -| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) | +| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 1-indexed), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) | | `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) | | `fn_task_done` | Mark task complete and optionally store summary | `summary?` (string) | | `fn_review_step` | Spawn step plan/code reviewer | `step` (number), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) | diff --git a/packages/cli/src/__tests__/task-plan.test.ts b/packages/cli/src/__tests__/task-plan.test.ts index 82380561c8..a8ae3ebd3b 100644 --- a/packages/cli/src/__tests__/task-plan.test.ts +++ b/packages/cli/src/__tests__/task-plan.test.ts @@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({ })); // Mock @fusion/core before importing -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), TaskStore: vi.fn(), COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], COLUMN_LABELS: { diff --git a/packages/cli/src/__tests__/task-steer.test.ts b/packages/cli/src/__tests__/task-steer.test.ts index 7cfa7f143a..bec5070b4d 100644 --- a/packages/cli/src/__tests__/task-steer.test.ts +++ b/packages/cli/src/__tests__/task-steer.test.ts @@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({ })); // Mock @fusion/core before importing -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), TaskStore: vi.fn(), COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], COLUMN_LABELS: { diff --git a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts index 4daa09537b..9e2d09ac07 100644 --- a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts +++ b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts @@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({ spawn: mockSpawn, })); -import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js"; +import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js"; type MockProcess = EventEmitter & { stdout: EventEmitter; @@ -75,8 +75,8 @@ describe("startup-model-sync", () => { expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) })); expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ models: expect.arrayContaining([ - expect.objectContaining({ id: "opencode-go/gpt-5" }), - expect.objectContaining({ id: "opencode-go/custom" }), + expect.objectContaining({ id: "gpt-5" }), + expect.objectContaining({ id: "custom" }), ]), })); expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced")); @@ -257,7 +257,7 @@ describe("startup-model-sync", () => { expect(result).toEqual({ registeredCount: 1 }); expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ - models: [expect.objectContaining({ id: "opencode-go/gpt-5" })], + models: [expect.objectContaining({ id: "gpt-5" })], })); }); @@ -319,4 +319,52 @@ describe("startup-model-sync", () => { "opencode-go/custom", ]); }); + + it("deduplicates models when CLI emits both prefix forms", async () => { + mockSpawn.mockImplementation(() => { + const proc = createSpawnProcess(); + queueMicrotask(() => { + proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n")); + proc.emit("exit", 0); + }); + return proc; + }); + + const registerProvider = vi.fn(); + await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() }); + + expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ + models: [ + expect.objectContaining({ id: "foo" }), + expect.objectContaining({ id: "bar" }), + ], + })); + }); + + it("throws on empty model ID after prefix stripping", () => { + expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name"); + expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name"); + }); + + it("accepts apiKey and passes it as env var to spawn", async () => { + mockSpawn.mockImplementation(() => { + const proc = createSpawnProcess(); + queueMicrotask(() => { + proc.stdout.emit("data", Buffer.from("opencode/foo\n")); + proc.emit("exit", 0); + }); + return proc; + }); + + const registerProvider = vi.fn(); + await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" }); + + expect(mockSpawn).toHaveBeenCalledWith( + "opencode", + ["models", "opencode", "--refresh"], + expect.objectContaining({ + env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }), + }), + ); + }); }); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 27d1af4f67..31172e6bb7 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -71,7 +71,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes @@ -720,14 +720,12 @@ export async function runDaemon(opts: DaemonOptions = {}) { if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => console.log(`[${scope}] ${message}`), - }); + (scope, message) => console.log(`[${scope}] ${message}`), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index 7eebd8d1f6..eca1bb773f 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -643,6 +643,89 @@ describe("Board view", () => { }); }); +describe("Board view — U11 custom-column graceful degradation (R18)", () => { + function renderBoardWithTasks(tasks: TaskItem[], cols = 200, rows = 50) { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + controller.setInteractiveData(makeInteractiveData({ + projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }], + tasks, + })); + controller.setMode("interactive"); + controller.setInteractiveView("board"); + const rendered = render(renderDashboardAppNode(controller)); + setTerminalSize(rendered, cols, rows); + rendered.rerender(renderDashboardAppNode(controller)); + return rendered; + } + + it("renders an 'Other (custom)' bucket between in-review and done", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "t1", title: "Legacy", description: "", column: "todo" }, + ]); + await waitForFrameContains(lastFrame, "OTHER (CUSTOM)"); + const frame = lastFrame() ?? ""; + const headerLine = frame.split("\n").find((l) => l.includes("OTHER (CUSTOM)")) ?? ""; + // Column headers share a row; verify in-review precedes other precedes done. + expect(headerLine.indexOf("IN REVIEW")).toBeLessThan(headerLine.indexOf("OTHER (CUSTOM)")); + expect(headerLine.indexOf("OTHER (CUSTOM)")).toBeLessThan(headerLine.indexOf("DONE")); + unmount(); + }); + + it("never drops a card: a task in an unknown column with no flags surfaces in 'Other (custom)' with its column name", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "drop1", title: "Should Not Vanish", description: "", column: "staging", columnName: "Staging" }, + ]); + await waitForFrameContains(lastFrame, "Should Not Vanish"); + const frame = lastFrame() ?? ""; + // The card is visible AND shows its real column name as a secondary label. + expect(frame).toContain("Should Not Vanish"); + expect(frame).toContain("Staging"); + // Other bucket header count reflects the card. + expect(frame).toContain("OTHER (CUSTOM) (1)"); + unmount(); + }); + + it("maps trait-flagged custom columns into legacy buckets (intake→todo, mergeBlocker→in-review, complete→done, wip→in-progress)", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "i", title: "IntakeCard", description: "", column: "triage", columnName: "Triage", columnFlags: { intake: true } }, + { id: "r", title: "ReviewCard", description: "", column: "gate", columnName: "Gate", columnFlags: { mergeBlocker: true } }, + { id: "d", title: "DoneCard", description: "", column: "shipped", columnName: "Shipped", columnFlags: { complete: true } }, + { id: "w", title: "WipCard", description: "", column: "building", columnName: "Building", columnFlags: { countsTowardWip: true } }, + ]); + await waitForFrameContains(lastFrame, "IntakeCard"); + const frame = lastFrame() ?? ""; + // All four mapped to legacy buckets; none in the "other" bucket. + expect(frame).toContain("TODO (1)"); + expect(frame).toContain("IN PROGRESS (1)"); + expect(frame).toContain("IN REVIEW (1)"); + expect(frame).toContain("DONE (1)"); + expect(frame).toContain("OTHER (CUSTOM) (0)"); + expect(frame).toContain("IntakeCard"); + expect(frame).toContain("ReviewCard"); + expect(frame).toContain("DoneCard"); + expect(frame).toContain("WipCard"); + unmount(); + }); + + it("shows a read-only move-disabled hint when the focused column is the 'other' bucket", async () => { + const { lastFrame, stdin, unmount } = renderBoardWithTasks([ + { id: "o1", title: "CustomCard", description: "", column: "staging", columnName: "Staging" }, + ]); + await waitForFrameContains(lastFrame, "CustomCard"); + // Buckets render left→right: todo, in-progress, in-review, other, done. + // Move focus right 3 times to land on the "other" bucket. + stdin.write(""); + await waitForFrameUpdateAfterInput(); + stdin.write(""); + await waitForFrameUpdateAfterInput(); + stdin.write(""); + await waitForFrameUpdateAfterInput(); + await waitForFrameContains(lastFrame, "move disabled here"); + unmount(); + }); +}); + describe("LogsPanel indicator", () => { it("renders the selection arrow on the highlighted log row", async () => { const controller = newController(); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts new file mode 100644 index 0000000000..b6b70d067e --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import type { TraitFlags } from "@fusion/core"; +import { + bucketForTask, + groupTasksByBucket, + otherBucketSecondaryLabel, + TUI_BUCKETS, + OTHER_BUCKET, + isOtherBucket, +} from "../bucket-mapping.js"; +import type { TaskItem } from "../state.js"; + +function task(partial: Partial & { id: string; column: string }): TaskItem { + return { description: "", ...partial }; +} + +describe("bucketForTask (U11 / R18 graceful degradation)", () => { + it("keeps legacy column ids in their own bucket verbatim", () => { + expect(bucketForTask(task({ id: "a", column: "todo" }))).toBe("todo"); + expect(bucketForTask(task({ id: "b", column: "in-progress" }))).toBe("in-progress"); + expect(bucketForTask(task({ id: "c", column: "in-review" }))).toBe("in-review"); + expect(bucketForTask(task({ id: "d", column: "done" }))).toBe("done"); + }); + + it("flag-OFF (no columnFlags) sends a non-legacy column to the read-only 'other' bucket — never dropped", () => { + expect(bucketForTask(task({ id: "x", column: "staging" }))).toBe(OTHER_BUCKET); + expect(bucketForTask(task({ id: "y", column: "qa-review" }))).toBe(OTHER_BUCKET); + }); + + it("maps each built-in trait flag to the correct legacy bucket", () => { + const cases: Array<[TraitFlags, string]> = [ + [{ complete: true }, "done"], + [{ humanReview: true }, "in-review"], + [{ mergeBlocker: true }, "in-review"], + [{ countsTowardWip: true }, "in-progress"], + [{ hold: true }, "todo"], + [{ intake: true }, "todo"], + ]; + for (const [flags, expected] of cases) { + expect( + bucketForTask(task({ id: "t", column: "custom-col", columnFlags: flags })), + ).toBe(expected); + } + }); + + it("applies flag precedence: complete > review > wip > todo-like", () => { + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { complete: true, countsTowardWip: false, humanReview: true } })), + ).toBe("done"); + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { humanReview: true, countsTowardWip: true } })), + ).toBe("in-review"); + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { countsTowardWip: true, hold: true } })), + ).toBe("in-progress"); + }); + + it("flagged column with no mapping-relevant flags lands in 'other'", () => { + expect( + bucketForTask(task({ id: "t", column: "weird", columnFlags: { notify: true, timing: true } })), + ).toBe(OTHER_BUCKET); + }); +}); + +describe("groupTasksByBucket", () => { + it("never drops a card: every task lands in exactly one of the five buckets", () => { + const tasks: TaskItem[] = [ + task({ id: "1", column: "todo" }), + task({ id: "2", column: "in-progress" }), + task({ id: "3", column: "in-review" }), + task({ id: "4", column: "done" }), + task({ id: "5", column: "triage", columnFlags: { intake: true } }), + task({ id: "6", column: "staging" }), // unmapped → other + task({ id: "7", column: "deploying", columnFlags: { notify: true } }), // unmapped → other + task({ id: "8", column: "blocked", columnFlags: { mergeBlocker: true } }), + ]; + const grouped = groupTasksByBucket(tasks); + const total = TUI_BUCKETS.reduce((n, b) => n + grouped[b].length, 0); + expect(total).toBe(tasks.length); + + const allIds = TUI_BUCKETS.flatMap((b) => grouped[b].map((t) => t.id)).sort(); + expect(allIds).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]); + + expect(grouped.todo.map((t) => t.id)).toEqual(["1", "5"]); // intake → todo + expect(grouped["in-review"].map((t) => t.id)).toEqual(["3", "8"]); // mergeBlocker → in-review + expect(grouped[OTHER_BUCKET].map((t) => t.id)).toEqual(["6", "7"]); + }); +}); + +describe("bucket ordering + labels", () => { + it("orders 'other' between in-review and done", () => { + expect([...TUI_BUCKETS]).toEqual(["todo", "in-progress", "in-review", OTHER_BUCKET, "done"]); + const reviewIdx = TUI_BUCKETS.indexOf("in-review"); + const otherIdx = TUI_BUCKETS.indexOf(OTHER_BUCKET); + const doneIdx = TUI_BUCKETS.indexOf("done"); + expect(otherIdx).toBeGreaterThan(reviewIdx); + expect(otherIdx).toBeLessThan(doneIdx); + }); + + it("isOtherBucket flags only the catch-all", () => { + expect(isOtherBucket(OTHER_BUCKET)).toBe(true); + expect(isOtherBucket("done")).toBe(false); + }); + + it("secondary label uses the real column name, falling back to the id", () => { + expect(otherBucketSecondaryLabel(task({ id: "a", column: "staging", columnName: "Staging area" }))).toBe("Staging area"); + expect(otherBucketSecondaryLabel(task({ id: "b", column: "qa-gate" }))).toBe("qa-gate"); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index ebb51aceb0..a8441ea749 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -70,6 +70,14 @@ import type { LogEntry } from "./log-ring-buffer.js"; import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js"; import { useProjects, useTasks } from "./hooks/use-projects.js"; import { copyToClipboard } from "./utils.js"; +import { + TUI_BUCKETS, + OTHER_BUCKET, + isOtherBucket, + groupTasksByBucket, + otherBucketSecondaryLabel, + type TuiBucket, +} from "./bucket-mapping.js"; // ── Format helpers ──────────────────────────────────────────────────────────── @@ -1133,17 +1141,23 @@ function MainHeader({ state }: { state: DashboardState }) { // ── Kanban board ────────────────────────────────────────────────────────────── -const KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const; -type KanbanColumn = typeof KANBAN_COLUMNS[number]; +// U11 (R18): the TUI renders five buckets — its four legacy kanban columns plus +// a read-only "Other (custom)" catch-all wedged between in-review and done for +// workflow columns it can't express. `KANBAN_COLUMNS` is the bucket render +// order; see ./bucket-mapping.ts for how tasks land in each. +const KANBAN_COLUMNS = TUI_BUCKETS; +type KanbanColumn = TuiBucket; -const COLUMN_COLORS: Record = { +const COLUMN_COLORS: Record = { todo: "yellow", "in-progress": "cyanBright", "in-review": "cyan", + [OTHER_BUCKET]: "magenta", done: "green", }; function columnLabel(col: string): string { + if (col === OTHER_BUCKET) return "Other (custom)"; return col.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } @@ -1151,10 +1165,14 @@ function TaskCard({ task, selected, width, + secondaryLabel, }: { task: TaskItem; selected: boolean; width: number; + // U11: real column name shown under cards in the read-only "other" bucket so + // the user keeps the true position despite the TUI not modeling that column. + secondaryLabel?: string; }) { const { t } = useTranslation("cli"); const accent = COLUMN_COLORS[task.column] ?? "white"; @@ -1180,6 +1198,9 @@ function TaskCard({ {title} + {secondaryLabel && ( + ↳ {secondaryLabel} + )} ); } @@ -1201,6 +1222,7 @@ function KanbanColumnView({ }) { const accent = COLUMN_COLORS[column]; const headerColor = isFocused ? "whiteBright" : accent; + const isOther = isOtherBucket(column); const cardWidth = Math.max(16, width - 2); const innerHeaderWidth = Math.max(8, width - 2); const label = `${columnLabel(column).toUpperCase()} (${tasks.length})`; @@ -1250,6 +1272,7 @@ function KanbanColumnView({ task={task} selected={isFocused && (windowStart + i) === selectedIndex} width={cardWidth} + secondaryLabel={isOther ? otherBucketSecondaryLabel(task) : undefined} /> ))} {hiddenBelow > 0 && ( @@ -1590,6 +1613,15 @@ function TaskDetailScreen({ )} + {/* Card-placed custom fields (U13/KTD-14): read-only bracketed labels. */} + {detail.customFields && detail.customFields.length > 0 && ( + + {detail.customFields.map((f) => ( + [{f.label}: {f.value}] + ))} + + )} + {/* Steps section */} @@ -1695,21 +1727,10 @@ function clamp(n: number, min: number, max: number): number { return Math.max(min, Math.min(max, n)); } -function groupTasksByColumn(tasks: TaskItem[]): Record { - const out: Record = { - todo: [], - "in-progress": [], - "in-review": [], - done: [], - }; - for (const task of tasks) { - const col = (KANBAN_COLUMNS as readonly string[]).includes(task.column) - ? (task.column as KanbanColumn) - : "todo"; - out[col].push(task); - } - return out; -} +// U11 (R18): bucket tasks into the five TUI buckets via trait-flag mapping so +// cards in workflow columns the TUI can't express land in a legacy bucket or +// the read-only "other" bucket — never dropped. Delegates to the shared helper. +const groupTasksByColumn = groupTasksByBucket; function BoardView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) { const { t } = useTranslation("cli"); @@ -1734,6 +1755,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D todo: 0, "in-progress": 0, "in-review": 0, + [OTHER_BUCKET]: 0, done: 0, }); const [pickerOriginal, setPickerOriginal] = useState(0); @@ -1859,13 +1881,22 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D const narrowColumnIndicator = isNarrow ? ` · ${colIndex + 1}/${KANBAN_COLUMNS.length} ${columnLabel(focusedColumn).toUpperCase()} (${focusedTasks.length})` : ""; + // U11 (R18): the "other" bucket is a read-only view of custom workflow + // columns the TUI can't model — moving cards into/out of it from here would + // mean expressing a column the TUI has no name for, so it's disabled with a + // hint. (The TUI has no in-place move action yet; this keeps the affordance + // honest for when one lands.) + const focusedIsReadOnly = isOtherBucket(focusedColumn); + const boardHint = focusedIsReadOnly + ? `←→ column · ↑↓ task · Enter open · ${t("tui.boardOtherReadOnlyHint", "custom column — move disabled here")}` + : `←→ column · ↑↓ task · Enter open · n new · p project`; const hintText = subView === "picker" ? "↑↓ pick · Enter confirm · Esc cancel" : subView === "detail" ? "Esc back · q quit" : subView === "create" ? "type a task title · Enter create · Esc cancel" - : `←→ column · ↑↓ task · Enter open · n new · p project${narrowColumnIndicator}`; + : `${boardHint}${narrowColumnIndicator}`; const submitNewTask = async () => { const title = newTaskTitle.trim(); diff --git a/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts b/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts new file mode 100644 index 0000000000..7f4f92afa6 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts @@ -0,0 +1,84 @@ +import type { TraitFlags } from "@fusion/core"; +import type { TaskItem } from "./state.js"; + +// ── TUI bucket model (U11, R18) ────────────────────────────────────────────── +// +// The TUI renders a fixed set of buckets. The first four are the legacy kanban +// columns it has always shown. The fifth, "other", is a read-only catch-all for +// cards whose resolved workflow column the TUI cannot express as one of its +// legacy buckets — it sits between "in-review" and "done" so a custom column +// roughly "after review, before done" reads in a sensible place, and each card +// in it keeps its real column name as a secondary label so the user never loses +// the true position. The cardinal rule (R18): a card is NEVER dropped. + +export const LEGACY_KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const; +export type LegacyKanbanColumn = (typeof LEGACY_KANBAN_COLUMNS)[number]; + +/** The "other (custom)" catch-all bucket id. Read-only. */ +export const OTHER_BUCKET = "other" as const; + +/** All TUI buckets in render order: legacy columns with "other" wedged between + * in-review and done (U11 ordering requirement). */ +export const TUI_BUCKETS = ["todo", "in-progress", "in-review", OTHER_BUCKET, "done"] as const; +export type TuiBucket = (typeof TUI_BUCKETS)[number]; + +/** True when the bucket is the read-only custom catch-all. */ +export function isOtherBucket(bucket: TuiBucket): bucket is typeof OTHER_BUCKET { + return bucket === OTHER_BUCKET; +} + +/** + * Map a task to its TUI bucket (U11, R18). + * + * 1. If the task sits in one of the legacy kanban column ids, keep it there + * verbatim — flag-OFF and all-legacy boards behave exactly as before. + * 2. Otherwise, if the task carries resolved column flags (flag-ON payload), + * map by trait flags into the nearest legacy bucket: + * - complete → done + * - humanReview || mergeBlocker → in-review + * - countsTowardWip → in-progress + * - hold || intake → todo + * 3. Anything still unmapped lands in the read-only "other" bucket. The card is + * never dropped. + * + * Precedence note: `complete` wins over the others (a terminal column is shown + * as done even if it carried other advisory flags); review beats wip; wip beats + * the todo-like flags. This mirrors the lane priority the dashboard board uses. + */ +export function bucketForTask(task: TaskItem): TuiBucket { + if ((LEGACY_KANBAN_COLUMNS as readonly string[]).includes(task.column)) { + return task.column as LegacyKanbanColumn; + } + + const flags: TraitFlags | undefined = task.columnFlags; + if (flags) { + if (flags.complete) return "done"; + if (flags.humanReview || flags.mergeBlocker) return "in-review"; + if (flags.countsTowardWip) return "in-progress"; + if (flags.hold || flags.intake) return "todo"; + } + + return OTHER_BUCKET; +} + +/** Group tasks into the five TUI buckets, preserving input order within each. + * Every task lands in exactly one bucket; none are dropped. */ +export function groupTasksByBucket(tasks: TaskItem[]): Record { + const out: Record = { + todo: [], + "in-progress": [], + "in-review": [], + [OTHER_BUCKET]: [], + done: [], + }; + for (const task of tasks) { + out[bucketForTask(task)].push(task); + } + return out; +} + +/** Secondary label shown under a card in the "other" bucket: its real column + * name (falling back to the column id) so the user keeps the true position. */ +export function otherBucketSecondaryLabel(task: TaskItem): string { + return task.columnName ?? task.column; +} diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index 2749048e37..8a144a2ac5 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -1,3 +1,4 @@ +import type { TraitFlags } from "@fusion/core"; import type { LogEntry } from "./log-ring-buffer.js"; // ── Public types shared across the whole dashboard-tui module ───────────────── @@ -131,6 +132,15 @@ export interface TaskItem { description: string; column: string; agentState?: string; + /** Display name of the task's resolved workflow column (U11, flag-ON only). + * Used as the secondary label when a card lands in the "Other (custom)" + * bucket so the user keeps the real position. Absent on flag-OFF / legacy + * boards. */ + columnName?: string; + /** Merged trait flags of the task's resolved workflow column (U11, flag-ON + * only). Drives graceful-degradation bucket mapping for non-legacy columns. + * Absent on flag-OFF / legacy boards, where bucketing is by column id. */ + columnFlags?: TraitFlags; } // Slim agent shape for Agents view list @@ -220,6 +230,10 @@ export interface TaskDetailData { currentStepIndex?: number; steps: TaskStep[]; recentLogs: TaskLogEntry[]; // last ~200 entries on initial load + /** Card-placed custom field values, pre-rendered as read-only bracketed + * labels for the task detail view (U13/KTD-14). Absent/empty when the + * workflow declares no card fields or none have values. */ + customFields?: Array<{ label: string; value: string }>; } export type TaskEvent = diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cb7fa0248f..a54b69519d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -16,6 +16,12 @@ import { GlobalSettingsStore, resolveGlobalDir, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, + isWorkflowColumnsEnabled, + resolveColumnFlags, + BUILTIN_CODING_WORKFLOW_IR, + parseWorkflowIr, + type WorkflowIrColumn, + type TraitFlags, } from "@fusion/core"; import { createServer, @@ -78,7 +84,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js" import { resolveSelfExtension } from "./self-extension.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js"; import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js"; @@ -936,6 +942,50 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: return projectStore; } + // ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ── + // + // The CLI TUI degrades gracefully (R18): cards in workflow columns it can't + // express must map by trait flags into its buckets or a read-only "other" + // bucket, never silently disappear. The TUI is flag-blind, so when + // `workflowColumns` is ON we enrich each slim task with its resolved column's + // display name + merged trait flags. Self-contained: derives everything from + // already-exposed store methods (workflow selection + definition) + the core + // `resolveColumnFlags` export — no dependency on concurrent U9 server work. + // Flag-OFF: returns undefineds and the TUI renders exactly as before. + type ResolvedColumnInfo = { columnName?: string; columnFlags?: TraitFlags }; + async function resolveTaskColumnInfo( + projectStore: TaskStore, + flagOn: boolean, + workflowIrCache: Map, + task: { id: string; column: string }, + ): Promise { + if (!flagOn) return {}; + try { + const selection = projectStore.getTaskWorkflowSelection(task.id); + const workflowId = selection?.workflowId; + let columns = workflowIrCache.get(workflowId); + if (columns === undefined) { + // Resolve the governing workflow IR. No selection → built-in default + // (KTD-1), matching the store's own resolution order. + const def = workflowId + ? await projectStore.getWorkflowDefinition(workflowId) + : undefined; + const ir = def?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + columns = ir.version === "v2" ? ir.columns : []; + workflowIrCache.set(workflowId, columns); + } + if (!columns) return {}; + // `task.column` is the IR column id (the store stores the column id). + const column = columns.find((c) => c.id === task.column); + if (!column) return {}; + return { columnName: column.name, columnFlags: resolveColumnFlags(column) }; + } catch { + // Degrade silently: an unresolvable workflow must never drop a card, just + // fall back to legacy column-id bucketing in the TUI. + return {}; + } + } + /** * Debounced refresh of TUI stats - batches rapid task updates. * If the BoardView has a scoped project path set on the controller, @@ -1711,14 +1761,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => logSink.log(message, scope), - }); + (scope, message) => logSink.log(message, scope), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); @@ -2032,14 +2080,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => logSink.log(message, scope), - }); + (scope, message) => logSink.log(message, scope), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); @@ -2378,13 +2424,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: listTasks: async (projectPath: string) => { const projectStore = await getProjectStore(projectPath); const tasks = await projectStore.listTasks({ slim: true, includeArchived: false }); - return tasks.map((t) => ({ - id: t.id, - title: t.title, - description: t.description ?? "", - column: t.column, - agentState: (t as { agentState?: string }).agentState, - })); + // U11 (R18): when the workflow-columns flag is ON, enrich each task + // with its resolved column display name + trait flags so the + // flag-blind TUI can map non-legacy columns into its buckets (or the + // read-only "other" bucket) instead of silently dropping them. The + // IR cache keeps this O(workflows) rather than O(tasks) DB reads. + const settings = await projectStore.getSettings(); + const flagOn = isWorkflowColumnsEnabled(settings); + const workflowIrCache = new Map(); + return Promise.all( + tasks.map(async (t) => { + const info = await resolveTaskColumnInfo(projectStore, flagOn, workflowIrCache, t); + return { + id: t.id, + title: t.title, + description: t.description ?? "", + column: t.column, + agentState: (t as { agentState?: string }).agentState, + ...(info.columnName !== undefined ? { columnName: info.columnName } : {}), + ...(info.columnFlags !== undefined ? { columnFlags: info.columnFlags } : {}), + }; + }), + ); }, createTask: async (projectPath: string, input: { title: string; description?: string }) => { const projectStore = await getProjectStore(projectPath); @@ -2678,6 +2739,48 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: text: entry.outcome ? `${entry.action} → ${entry.outcome}` : entry.action, source: entry.runContext?.agentId ? "agent" : "executor", })); + // Card-placed custom fields → read-only bracketed labels + // (U13/KTD-14). Resolve the task's workflow IR, filter + // card-placed field defs, and render any present values. + // Best-effort: any resolution failure simply omits the chips. + let customFields: Array<{ label: string; value: string }> | undefined; + try { + const values = (t as { customFields?: Record }).customFields; + if (values && Object.keys(values).length > 0) { + const selection = projectStore.getTaskWorkflowSelection(t.id); + const def = selection?.workflowId + ? await projectStore.getWorkflowDefinition(selection.workflowId) + : undefined; + const ir = def + ? (typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir) + : BUILTIN_CODING_WORKFLOW_IR; + const fields = ir.version === "v2" ? (ir.fields ?? []) : []; + const chips: Array<{ label: string; value: string }> = []; + for (const field of fields) { + if (field.render?.placement !== "card") continue; + const raw = values[field.id]; + if (raw === undefined || raw === null || raw === "") continue; + const optLabel = (v: string): string => + field.options?.find((o) => o.value === v)?.label ?? v; + let display: string; + if (field.type === "boolean") { + if (raw !== true) continue; + display = field.name; + } else if (field.type === "multi-enum" && Array.isArray(raw)) { + if (raw.length === 0) continue; + display = raw.map((v) => optLabel(String(v))).join(", "); + } else if (field.type === "enum") { + display = optLabel(String(raw)); + } else { + display = String(raw); + } + chips.push({ label: field.name, value: display }); + } + if (chips.length > 0) customFields = chips; + } + } catch { + customFields = undefined; + } return { id: t.id, title: t.title, @@ -2689,6 +2792,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: currentStepIndex: t.currentStep, steps, recentLogs, + ...(customFields ? { customFields } : {}), }; } catch { // Task not found (deleted/archived between selection and fetch). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8f01b2c798..a19b35813a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -71,7 +71,7 @@ import { } from "./llama-cpp-extension.js"; import { resolveSelfExtension } from "./self-extension.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; @@ -827,14 +827,12 @@ export async function runServe( if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => console.log(`[${scope}] ${message}`), - }); + (scope, message) => console.log(`[${scope}] ${message}`), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/startup-model-sync.ts b/packages/cli/src/commands/startup-model-sync.ts index 79d3b88220..2700c448d6 100644 --- a/packages/cli/src/commands/startup-model-sync.ts +++ b/packages/cli/src/commands/startup-model-sync.ts @@ -205,15 +205,22 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti export function normalizeOpencodeGoModel(modelId: string): ModelConfig { const trimmed = modelId.trim(); - const normalizedId = trimmed.startsWith("opencode/") - ? `opencode-go/${trimmed.slice("opencode/".length)}` - : trimmed.startsWith("opencode-go/") - ? trimmed - : `opencode-go/${trimmed}`; + // Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK + // already routes requests by provider, and the OpenCode API expects the + // bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash"). + const bareModel = trimmed.startsWith("opencode-go/") + ? trimmed.slice("opencode-go/".length) + : trimmed.startsWith("opencode/") + ? trimmed.slice("opencode/".length) + : trimmed; + + if (!bareModel) { + throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`); + } return { - id: normalizedId, - name: normalizedId, + id: bareModel, + name: bareModel, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] { return [...ids]; } -export async function discoverOpencodeGoModels(): Promise { +export async function discoverOpencodeGoModels(apiKey?: string): Promise { return await new Promise((resolve, reject) => { + const env: Record = { ...process.env as Record }; + if (apiKey) { + env.OPENCODE_API_KEY = apiKey; + } const proc = spawn("opencode", ["models", "opencode", "--refresh"], { stdio: ["ignore", "pipe", "pipe"], + env, }); let stdout = ""; @@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise { export async function refreshOpencodeGoModels(options: { modelRegistry: ModelRegistryLike; log: (scope: string, message: string) => void; + apiKey?: string; }): Promise { try { - const { modelRegistry, log } = options; - const modelIds = await discoverOpencodeGoModels(); + const { modelRegistry, log, apiKey } = options; + const modelIds = await discoverOpencodeGoModels(apiKey); if (modelIds.length === 0) { log("opencode-go", "No models discovered from opencode CLI refresh"); return { registeredCount: 0, reason: "no-models-from-cli" }; } - const models = modelIds.map(normalizeOpencodeGoModel); + const normalized = modelIds.map(normalizeOpencodeGoModel); + // Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo" + // which normalize to the same bare ID. + const seen = new Set(); + const models = normalized.filter((m) => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); modelRegistry.registerProvider("opencode-go", { baseUrl: "https://api.opencode.ai/v1", apiKey: "OPENCODE_API_KEY", @@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise Promise }, + modelRegistry: ModelRegistryLike, + log: (scope: string, message: string) => void, +): Promise { + const settings = await store.getSettings(); + if (settings.opencodeGoModelSync === false) { + return { registeredCount: 0, reason: "disabled-by-settings" }; + } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); + return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey }); +} diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 79db647e37..5528028e59 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { aiMergeTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -19,6 +19,12 @@ import { findNodeByNameOrId } from "./node.js"; const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"]; +/** #1403: display a column's label, falling back to the raw id for + * workflow-defined custom columns that have no legacy label. */ +function columnLabel(column: ColumnId): string { + return (COLUMN_LABELS as Record)[column] ?? column; +} + // Register GitHub tracking hook so CLI task creation paths (add, duplicate, // refine, import, delegate) trigger tracking issue creation. try { @@ -806,7 +812,7 @@ export async function runTaskShow(id: string, projectName?: string) { console.log(); console.log(` ${task.id}: ${task.title || task.description}`); - console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`); + console.log(` Column: ${columnLabel(task.column)}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`); if (task.dependencies.length) { console.log(` Dependencies: ${task.dependencies.join(", ")}`); } @@ -959,7 +965,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri const task = await store.moveTask(id, column as Column); console.log(); - console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`); + console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`); console.log(); } @@ -1010,7 +1016,7 @@ export async function runTaskArchive(id: string, projectName?: string) { const task = await store.archiveTask(id); console.log(); - console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`); + console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`); console.log(); } @@ -1019,7 +1025,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) { const task = await store.unarchiveTask(id); console.log(); - console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`); + console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`); console.log(); } diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 7383485347..69c3111791 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -9,6 +9,7 @@ import { buildManualRetryResetPatch, validateNodeOverrideChange, type Task, + type ColumnId, type InsightCategory, type TaskPriority, type InsightStatus, @@ -57,6 +58,12 @@ import { spawn, type ChildProcess } from "node:child_process"; // ── Helpers ──────────────────────────────────────────────────────── +/** #1403: display a column's label, falling back to the raw id for + * workflow-defined custom columns that have no legacy label. */ +function columnLabel(column: ColumnId): string { + return (COLUMN_LABELS as Record)[column] ?? column; +} + const MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -782,7 +789,7 @@ export default function kbExtension(pi: ExtensionAPI) { const lines: string[] = []; lines.push(`${task.id}: ${task.title || task.description}`); lines.push( - `Column: ${COLUMN_LABELS[task.column]}` + + `Column: ${columnLabel(task.column)}` + (task.size ? ` · Size: ${task.size}` : "") + (task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""), ); @@ -1145,7 +1152,7 @@ export default function kbExtension(pi: ExtensionAPI) { const task = await store.archiveTask(params.id); return { - content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }], + content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, @@ -1173,7 +1180,7 @@ export default function kbExtension(pi: ExtensionAPI) { const task = await store.unarchiveTask(params.id); return { - content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }], + content: [{ type: "text", text: `Unarchived ${task.id} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, diff --git a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts new file mode 100644 index 0000000000..0fa5e80258 --- /dev/null +++ b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts @@ -0,0 +1,57 @@ +/** + * Drift guard for the intentionally duplicated resolvePluginEntryPath. + * + * The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks + * work in tests) while @fusion/core owns the copy used by the dashboard + * install/enable routes. This test runs both against real on-disk layouts and + * asserts identical results, so a candidate-list change applied to one copy + * but not the other fails CI instead of silently diverging. + * + * No fs mocks here on purpose — vitest module mocks don't reach the + * externalized @fusion/core import, so real temp directories are the only + * seam that exercises both implementations equally. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js"; +import { resolvePluginEntryPath as coreResolve } from "@fusion/core"; + +describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "entry-path-sync-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function touch(relative: string) { + const full = join(dir, relative); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, "// entry\n"); + } + + const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [ + { name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" }, + { name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" }, + { name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" }, + { name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" }, + { name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" }, + { name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" }, + { name: "no entry files", files: ["README.md"], expected: null }, + ]; + + for (const layout of layouts) { + it(`resolves identically for: ${layout.name}`, () => { + for (const f of layout.files) touch(f); + const expected = layout.expected === null ? null : join(dir, layout.expected); + + expect(cliResolve(dir)).toBe(expected); + expect(coreResolve(dir)).toBe(expected); + }); + } +}); diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 7ddf24a102..6f5beede25 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null { * Returns null when the directory exists but none of the loadable entry files * are present. Callers must treat that as a missing bundle rather than * persisting a directory path that Node cannot import. + * + * Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts), + * which the dashboard install/enable routes use for the same contract. */ export function resolvePluginEntryPath(pluginDir: string): string | null { const candidates = [ diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 581c768372..f02188255e 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -41,6 +41,8 @@ const reportsPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-r const reportsPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-reports"); const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-cli-printing-press"); const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press"); +const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-compound-engineering"); +const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering"); const dashboardClientStub = ` @@ -241,6 +243,12 @@ const cliBuildConfig = { destDir: roadmapPluginDest, }); + await bundlePluginEntry({ + pluginId: "fusion-plugin-compound-engineering", + srcDir: compoundEngineeringPluginSrc, + destDir: compoundEngineeringPluginDest, + }); + if (existsSync(reportsPluginDest)) { rmSync(reportsPluginDest, { recursive: true, force: true }); } diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 110f4905c8..3edf7b5f8d 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; -import { BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr, serializeWorkflowIr } from "../index.js"; +import { + BUILTIN_CODING_WORKFLOW_IR, + DEFAULT_WORKFLOW_COLUMN_IDS, + parseWorkflowIr, + serializeWorkflowIr, +} from "../index.js"; describe("builtin coding workflow ir", () => { it("parses and round-trips", () => { const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR); const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed)); expect(reparsed).toEqual(parsed); - expect(parsed.version).toBe("v1"); + // The built-in default workflow is now a v2 graph (columns + placement). + expect(parsed.version).toBe("v2"); }); it("contains exactly one start and one end node", () => { @@ -22,4 +28,34 @@ describe("builtin coding workflow ir", () => { expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"])); expect(seams).not.toContain("triage"); }); + + it("defines the six legacy columns in legacy order (KTD-1)", () => { + expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); + if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); + const ids = BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id); + expect(ids).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]); + expect(ids).toEqual(["triage", "todo", "in-progress", "in-review", "done", "archived"]); + }); + + it("maps default-workflow traits to columns verbatim (R12)", () => { + if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => [c.id, c])); + const traitsFor = (id: string) => byId.get(id)!.traits.map((t) => t.trait); + expect(traitsFor("triage")).toEqual(["intake"]); + expect(traitsFor("todo")).toEqual(["hold", "reset-on-entry"]); + expect(traitsFor("in-progress")).toEqual(["wip", "abort-on-exit", "timing"]); + expect(traitsFor("in-review")).toEqual(["merge-blocker", "stall-detection", "merge"]); + expect(traitsFor("done")).toEqual(["complete"]); + expect(traitsFor("archived")).toEqual(["archived"]); + // todo's hold is capacity-released (legacy "pull from todo when a slot frees"). + const hold = byId.get("todo")!.traits.find((t) => t.trait === "hold"); + expect(hold?.config?.release).toBe("capacity"); + }); + + it("places seam nodes in their columns", () => { + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); + expect(byId.get("execute")?.column).toBe("in-progress"); + expect(byId.get("review")?.column).toBe("in-review"); + expect(byId.get("merge")?.column).toBe("in-review"); + }); }); diff --git a/packages/core/src/__tests__/builtin-traits.test.ts b/packages/core/src/__tests__/builtin-traits.test.ts new file mode 100644 index 0000000000..10f0e76527 --- /dev/null +++ b/packages/core/src/__tests__/builtin-traits.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + BUILTIN_TRAIT_DEFINITIONS, + BUILTIN_TRAIT_IDS, + registerBuiltinTraits, +} from "../builtin-traits.js"; +import { TraitRegistry } from "../trait-registry.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { WorkflowIrV2 } from "../workflow-ir-types.js"; + +function freshRegistry(): TraitRegistry { + const r = new TraitRegistry(); + registerBuiltinTraits(r); + return r; +} + +describe("built-in traits", () => { + it("ships exactly the 14 vocabulary traits", () => { + expect(BUILTIN_TRAIT_IDS).toHaveLength(14); + expect(BUILTIN_TRAIT_DEFINITIONS.map((d) => d.id).sort()).toEqual([...BUILTIN_TRAIT_IDS].sort()); + }); + + it("all built-ins are flagged builtin: true and register cleanly", () => { + const r = freshRegistry(); + for (const id of BUILTIN_TRAIT_IDS) { + const def = r.getTrait(id); + expect(def, `missing built-in trait ${id}`).toBeDefined(); + expect(def?.builtin).toBe(true); + } + expect(r.listTraits()).toHaveLength(14); + }); + + it("only built-in traits carry restricted capabilities", () => { + const r = freshRegistry(); + expect(r.getTrait("complete")?.flags.complete).toBe(true); + expect(r.getTrait("archived")?.flags.archived).toBe(true); + // Sync guards live only on built-ins (merge-blocker, human-review). + expect(r.getTrait("merge-blocker")?.hooks?.guard).toBe(true); + expect(r.getTrait("human-review")?.hooks?.guard).toBe(true); + // The plugin-facing gate trait uses the async gate hook, not a sync guard. + expect(r.getTrait("gate")?.hooks?.guard).toBeUndefined(); + expect(r.getTrait("gate")?.hooks?.gate).toBe(true); + }); + + it("merge trait config schema matches the U7 policy fields", () => { + const r = freshRegistry(); + const fields = r.getTrait("merge")?.configSchema?.fields ?? []; + const keys = fields.map((f) => f.key).sort(); + // U7 tightened the schema: strategy enum, fileScope enum (incl. custom), + // custom-rules array, squash posture, conflictStrategy. + expect(keys).toEqual(["conflictStrategy", "fileScope", "rules", "squash", "strategy"]); + expect(r.getTrait("merge")?.flags.mergeOrchestration).toBe(true); + + const strategy = fields.find((f) => f.key === "strategy"); + expect(strategy?.enumValues).toEqual(["always-squash", "auto", "always-rebase", "pr-only"]); + const fileScope = fields.find((f) => f.key === "fileScope"); + expect(fileScope?.enumValues).toEqual(["strict", "warn", "off", "custom"]); + }); + + it("hold trait's release config matches WorkflowHoldRelease kinds", () => { + const r = freshRegistry(); + const release = r.getTrait("hold")?.configSchema?.fields.find((f) => f.key === "release"); + expect(release?.enumValues).toEqual([ + "manual", + "timer", + "capacity", + "dependency", + "external-event", + ]); + }); + + it("registering built-ins twice into the same registry is idempotent", () => { + const r = freshRegistry(); + expect(() => registerBuiltinTraits(r)).not.toThrow(); + expect(r.listTraits()).toHaveLength(14); + }); +}); + +describe("default workflow columns validate cleanly", () => { + it("BUILTIN_CODING_WORKFLOW_IR columns pass the composition validator", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const violations = r.validateColumnTraits(ir.columns, "save"); + expect(violations).toEqual([]); + }); + + it("the default workflow has exactly one intake column (triage)", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const intakeCols = ir.columns.filter((c) => r.resolveColumnFlags(c).intake); + expect(intakeCols.map((c) => c.id)).toEqual(["triage"]); + }); + + it("the default workflow's done column resolves the complete flag", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const done = ir.columns.find((c) => c.id === "done")!; + expect(r.resolveColumnFlags(done).complete).toBe(true); + }); + + it("the default workflow's in-progress column resolves wip+abort+timing flags", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const inProgress = ir.columns.find((c) => c.id === "in-progress")!; + const flags = r.resolveColumnFlags(inProgress); + expect(flags.countsTowardWip).toBe(true); + expect(flags.abortOnExit).toBe(true); + expect(flags.timing).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index b6e54b247e..78afed3ed2 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -1,20 +1,52 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "../builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { compileWorkflowToSteps } from "../workflow-compiler.js"; -import { parseWorkflowIr } from "../workflow-ir.js"; +import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr } from "../workflow-ir.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("built-in workflows", () => { - it("every built-in has a valid IR and compiles without error", () => { + // Graph-only built-ins (step inversion, KTD-9) model branching/foreach/rework + // structure the linear compiler cannot lower to a step list — they run only + // under the workflow graph executor. They still must parse as valid IR. + const GRAPH_ONLY_BUILTIN_IDS = new Set(["builtin:stepwise-coding"]); + + it("every built-in has a valid IR; linear built-ins compile without error", () => { expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4); for (const wf of BUILTIN_WORKFLOWS) { expect(isBuiltinWorkflowId(wf.id)).toBe(true); expect(() => parseWorkflowIr(wf.ir)).not.toThrow(); - expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow(); + if (!GRAPH_ONLY_BUILTIN_IDS.has(wf.id)) { + expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow(); + } } }); + it("includes the stepwise coding built-in modeling step inversion (KTD-9)", () => { + const stepwise = getBuiltinWorkflow("builtin:stepwise-coding"); + expect(stepwise).toBeDefined(); + const ir = parseWorkflowIr(stepwise!.ir); + if (ir.version !== "v2") throw new Error("expected v2"); + // The chain: a parse-steps node dominating a foreach with a step-review template. + expect(ir.nodes.some((n) => n.kind === "parse-steps")).toBe(true); + const foreach = ir.nodes.find((n) => n.kind === "foreach"); + expect(foreach).toBeDefined(); + const template = ( + foreach!.config as { template: { nodes: Array<{ kind: string; config?: { seam?: string } }> } } + ).template; + expect(template.nodes.some((n) => n.kind === "step-review")).toBe(true); + expect(template.nodes.some((n) => n.config?.seam === "step-execute")).toBe(true); + }); + + it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => { + expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); + if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); + expect(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id)).toEqual([ + ...DEFAULT_WORKFLOW_COLUMN_IDS, + ]); + }); + it("includes a coding and a compound-engineering workflow", () => { expect(getBuiltinWorkflow("builtin:coding")).toBeDefined(); expect(getBuiltinWorkflow("builtin:compound-engineering")).toBeDefined(); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index d4fec7f76a..b36c04e128 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -939,8 +939,68 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); + + it("adds workflow_run_step_instances table + tasks.customFields when migrating from schema version 107", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '107')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + + db.init(); + + // The new per-step-instance run-state table exists with its index. + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(tables.map((row) => row.name)).toContain("workflow_run_step_instances"); + + const stepInstanceColumns = db + .prepare("PRAGMA table_info(workflow_run_step_instances)") + .all() as Array<{ name: string }>; + expect(stepInstanceColumns.map((column) => column.name)).toEqual([ + "taskId", + "runId", + "foreachNodeId", + "stepIndex", + "pinnedStepCount", + "currentNodeId", + "status", + "baselineSha", + "checkpointId", + "reworkCount", + "branchName", + "integratedAt", + "updatedAt", + ]); + + const stepInstanceIndexes = db + .prepare("PRAGMA index_list(workflow_run_step_instances)") + .all() as Array<{ name: string }>; + expect( + stepInstanceIndexes.some((index) => index.name === "idx_workflow_run_step_instances_task_run"), + ).toBe(true); + + // tasks.customFields column is added with a default-'{}' definition. + const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ + name: string; + dflt_value: string | null; + }>; + const customFieldsColumn = taskColumns.find((column) => column.name === "customFields"); + expect(customFieldsColumn).toBeDefined(); + expect(customFieldsColumn?.dflt_value).toBe("'{}'"); + + expect(db.getSchemaVersion()).toBe(108); + db.close(); + }); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 4ad168b50b..8a618bd583 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); db.close(); }); @@ -1527,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1568,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1640,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1880,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1954,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1978,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2082,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2301,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(105); + expect(localDb.getSchemaVersion()).toBe(108); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2612,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2766,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(105); + expect(migrated.getSchemaVersion()).toBe(108); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2790,6 +2790,120 @@ describe("migration v77 task token budget columns", () => { }); }); +describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { + it("includes the transitionPending column on fresh init", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const fresh = new Database(fusion); + try { + fresh.init(); + expect(fresh.getSchemaVersion()).toBe(108); + const names = new Set( + (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), + ); + expect(names.has("transitionPending")).toBe(true); + } finally { + try { fresh.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); + + it("from v105 → init() adds transitionPending; existing rows keep it NULL and survive", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const localDb = new Database(fusion); + let migrated: Database | undefined; + try { + localDb.init(); + localDb + .prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .run("FN-V105", "pre-106 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + // Roll back to v105 and drop the column the v106 migration adds. + localDb.exec("ALTER TABLE tasks DROP COLUMN transitionPending"); + localDb.prepare("UPDATE __meta SET value = '105' WHERE key = 'schemaVersion'").run(); + localDb.close(); + + migrated = new Database(fusion); + migrated.init(); + expect(migrated.getSchemaVersion()).toBe(108); + const names = new Set( + (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), + ); + expect(names.has("transitionPending")).toBe(true); + const row = migrated + .prepare("SELECT id, transitionPending FROM tasks WHERE id = ?") + .get("FN-V105") as { id: string; transitionPending: string | null } | undefined; + expect(row?.id).toBe("FN-V105"); + // Additive, nullable, no backfill — the pre-existing row stays NULL. + expect(row?.transitionPending).toBeNull(); + } finally { + try { migrated?.close(); } catch { /* already closed */ } + try { localDb.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); +}); + +describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { + it("creates the workflow_run_branches table and its index on fresh init", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const fresh = new Database(fusion); + try { + fresh.init(); + expect(fresh.getSchemaVersion()).toBe(108); + const table = fresh + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("workflow_run_branches"); + const index = fresh + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'") + .get() as { name: string } | undefined; + expect(index?.name).toBe("idx_workflow_run_branches_task_run"); + } finally { + try { fresh.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); + + it("from v106 → init() adds workflow_run_branches + index without dropping existing rows", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const localDb = new Database(fusion); + let migrated: Database | undefined; + try { + localDb.init(); + localDb + .prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .run("FN-V106", "pre-107 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + // Roll back to v106 and drop the table the v107 migration creates. (v106 + // schema already has tasks.transitionPending, so we leave it in place.) + localDb.exec("DROP INDEX IF EXISTS idx_workflow_run_branches_task_run"); + localDb.exec("DROP TABLE IF EXISTS workflow_run_branches"); + localDb.prepare("UPDATE __meta SET value = '106' WHERE key = 'schemaVersion'").run(); + localDb.close(); + + migrated = new Database(fusion); + migrated.init(); + expect(migrated.getSchemaVersion()).toBe(108); + const table = migrated + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("workflow_run_branches"); + const index = migrated + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'") + .get() as { name: string } | undefined; + expect(index?.name).toBe("idx_workflow_run_branches_task_run"); + const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V106") as { id: string } | undefined; + expect(task?.id).toBe("FN-V106"); + } finally { + try { migrated?.close(); } catch { /* already closed */ } + try { localDb.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); +}); + describe("migration v67 drops orphan project auth tables", () => { it("drops project_auth_* tables left over from the removed pluggable auth feature", () => { const temp = makeTmpDir(); @@ -2812,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(105); + expect(migrated.getSchemaVersion()).toBe(108); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2839,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(105); + expect(fresh.getSchemaVersion()).toBe(108); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/default-workflow-hooks.test.ts b/packages/core/src/__tests__/default-workflow-hooks.test.ts new file mode 100644 index 0000000000..c650a89a78 --- /dev/null +++ b/packages/core/src/__tests__/default-workflow-hooks.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +// +// U4: the default-workflow side effects are resolved THROUGH the trait registry +// (the DI seam, KTD-2/U2). This pins: +// - registerDefaultWorkflowHooks() wires the impls so resolution finds them +// (no missing-hook-impl warning on the happy path); +// - a missing registration degrades to a no-op + audit warning (not a crash); +// - applyDefaultWorkflowMoveEffects mutates the task per the legacy contract. + +import { describe, it, expect, beforeEach } from "vitest"; +import { + __resetTraitRegistryForTests, + getTraitRegistry, +} from "../trait-registry.js"; +import { registerBuiltinTraits } from "../builtin-traits.js"; +import { + __resetDefaultWorkflowHooksForTests, + applyDefaultWorkflowMoveEffects, + registerDefaultWorkflowHooks, + type DefaultWorkflowMoveContext, +} from "../default-workflow-hooks.js"; +import type { Task } from "../types.js"; + +function makeCtx(overrides: Partial = {}): DefaultWorkflowMoveContext { + const task = { + id: "FN-1", + column: "in-progress", + columnMovedAt: new Date().toISOString(), + steps: [], + dependencies: [], + } as unknown as Task; + return { + task, + fromColumn: "todo", + toColumn: "in-progress", + moveSource: "user", + bypassGuards: false, + movedAt: new Date().toISOString(), + settings: undefined, + options: {}, + resetSteps: () => {}, + ...overrides, + }; +} + +describe("default-workflow-hooks registry wiring", () => { + beforeEach(() => { + __resetTraitRegistryForTests(); + __resetDefaultWorkflowHooksForTests(); + registerBuiltinTraits(); + }); + + it("resolves all default-workflow hooks without a missing-impl warning once registered", () => { + registerDefaultWorkflowHooks(); + const ctx = makeCtx({ fromColumn: "todo", toColumn: "in-progress" }); + const { warnings } = applyDefaultWorkflowMoveEffects(ctx); + expect(warnings).toHaveLength(0); + // timing.onEnter stamped cumulativeActiveMs on entry to in-progress. + expect(ctx.task.cumulativeActiveMs).toBe(0); + }); + + it("degrades to a no-op + audit warning when a hook impl is not registered", () => { + // Built-in DEFINITIONS are registered (so the trait declares the hook) but + // we deliberately do NOT call registerDefaultWorkflowHooks() — no impls. + const registry = getTraitRegistry(); + // sanity: the trait declares the hook descriptor + expect(registry.getTrait("timing")?.hooks?.onEnter).toBe(true); + const ctx = makeCtx({ fromColumn: "todo", toColumn: "in-progress" }); + const { warnings } = applyDefaultWorkflowMoveEffects(ctx); + // Every declared hook with no impl yields a degraded-no-op warning. + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.every((w) => w.kind === "missing-hook-impl")).toBe(true); + // No crash; task unmutated by the (no-op) hooks. + expect(ctx.task.cumulativeActiveMs).toBeUndefined(); + }); + + it("applies userPaused only for user-source reopen to todo", () => { + registerDefaultWorkflowHooks(); + const userCtx = makeCtx({ fromColumn: "in-progress", toColumn: "todo", moveSource: "user" }); + applyDefaultWorkflowMoveEffects(userCtx); + expect(userCtx.task.userPaused).toBe(true); + + const engineCtx = makeCtx({ fromColumn: "in-progress", toColumn: "todo", moveSource: "engine" }); + applyDefaultWorkflowMoveEffects(engineCtx); + expect(engineCtx.task.userPaused).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index ad15bac419..333a68e551 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 192f454f17..7aa0e611ad 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(108); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(105); + expect(db3.getSchemaVersion()).toBe(108); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(108); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(105); + expect(db2.getSchemaVersion()).toBe(108); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(108); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ca23e17058..ef2a10d136 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/migration-workflow-columns.test.ts b/packages/core/src/__tests__/migration-workflow-columns.test.ts new file mode 100644 index 0000000000..84669edf16 --- /dev/null +++ b/packages/core/src/__tests__/migration-workflow-columns.test.ts @@ -0,0 +1,439 @@ +// @vitest-environment node +// +// U12: workflow-columns migration / integrity / graduation + rollback safety. +// +// Proves the U12 plan scenarios: +// - Migration rewrites ZERO task rows (KTD-1): fresh DB and an aged fixture DB +// (tasks in every legacy column, some with workflow selections) resolve every +// task to a valid (workflow, column) pair. +// - The integrity pass re-homes a task whose stored column is invalid in its +// resolved workflow, and is IDEMPOTENT (a second run is a no-op). +// - done/archived (terminal) cards are left untouched by the integrity pass. +// - Flag OFF after running flag-ON: legacy board + engine behavior intact. +// - Deliberate parity-drift injection (altered default-workflow adjacency) is +// CAUGHT by the graduation report's transition-parity gate. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { workflowHasColumn } from "../workflow-transitions.js"; +import { + checkTransitionParity, + computeWorkflowColumnsGraduationReport, + countDualAcceptDisagreements, +} from "../workflow-parity.js"; +import type { Column } from "../types.js"; + +function customIr(name: string, cols: string[], entryId: string): WorkflowIr { + return { + version: "v2", + name, + columns: cols.map((id) => ({ + id, + name: id, + traits: id === entryId ? [{ trait: "intake" }] : [], + })), + nodes: [ + { id: "start", kind: "start", column: entryId }, + { id: "work", kind: "prompt", column: cols[1] ?? entryId, config: { prompt: "do" } }, + { id: "end", kind: "end", column: cols[cols.length - 1] }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +describe("U12 migration — zero task-row rewrites (KTD-1)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function seedInColumn(column: Column): Promise { + const task = await store.createTask({ description: `seed-${column}` }); + const u = { moveSource: "user" as const }; + if (column === "triage") return task.id; + await store.moveTask(task.id, "todo", u); + if (column === "todo") return task.id; + await store.moveTask(task.id, "in-progress", u); + if (column === "in-progress") return task.id; + await store.moveTask(task.id, "in-review", { ...u, allowDirectInReviewMove: true }); + if (column === "in-review") return task.id; + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + if (column === "done") return task.id; + await store.moveTask(task.id, "archived", u); + return task.id; + } + + it("fresh DB: a default-workflow task resolves to a valid (workflow, column) pair", async () => { + const id = await seedInColumn("todo"); + const task = await store.getTask(id); + expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, task.column)).toBe(true); + }); + + it("aged fixture: tasks in every legacy column all resolve to a valid column; integrity pass touches none", async () => { + const ids: string[] = []; + for (const col of ["triage", "todo", "in-progress", "in-review", "done", "archived"] as Column[]) { + ids.push(await seedInColumn(col)); + } + // A task with a custom-workflow selection whose column IS valid in it. + const wf = await store.createWorkflowDefinition({ + name: "valid-custom", + ir: customIr("valid-custom", ["todo", "build", "done"], "todo"), + }); + const customTask = await store.createTask({ description: "custom" }); + await store.moveTask(customTask.id, "todo", { moveSource: "user" }); + await store.selectTaskWorkflowAndReconcile(customTask.id, wf.id); + + const before = await Promise.all(ids.map((id) => store.getTask(id))); + const result = await store.runWorkflowColumnsIntegrityPass(); + // No row was invalid → nothing re-homed. + expect(result.rehomed).toBe(0); + + const after = await Promise.all(ids.map((id) => store.getTask(id))); + for (let i = 0; i < ids.length; i += 1) { + expect(after[i].column).toBe(before[i].column); + } + }); +}); + +describe("U12 integrity pass — invalid column re-home + idempotency + terminal-untouched", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + function rawDb(): { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } { + return (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + } + + it("re-homes a task whose stored column is invalid in its resolved workflow, and is idempotent", async () => { + // Select a custom workflow that defines [stage-a, stage-b, finished], then + // force the stored column to one that workflow never defines. + const wf = await store.createWorkflowDefinition({ + name: "drifted", + ir: customIr("drifted", ["stage-a", "stage-b", "finished"], "stage-a"), + }); + const task = await store.createTask({ description: "drifter" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + // Out-of-band corruption: stored column not in the workflow. + rawDb().prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("ghost-column", task.id); + + const first = await store.runWorkflowColumnsIntegrityPass(); + expect(first.rehomed).toBe(1); + const afterFirst = await store.getTask(task.id); + expect(afterFirst.column).toBe("stage-a"); // entry (intake) column + + // Idempotent: a second run finds nothing out of place. + const second = await store.runWorkflowColumnsIntegrityPass(); + expect(second.rehomed).toBe(0); + expect((await store.getTask(task.id)).column).toBe("stage-a"); + }); + + it("leaves done/archived (terminal) cards untouched even if their column were invalid", async () => { + // A task selecting a custom workflow that lacks "done" but the task sits in + // "done" — terminal cards are never re-homed. + const wf = await store.createWorkflowDefinition({ + name: "no-done", + ir: customIr("no-done", ["start-col", "mid-col", "fin-col"], "start-col"), + }); + const task = await store.createTask({ description: "terminal" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + rawDb().prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("done", task.id); + + const result = await store.runWorkflowColumnsIntegrityPass(); + expect(result.skippedTerminal).toBeGreaterThanOrEqual(1); + expect((await store.getTask(task.id)).column).toBe("done"); + }); +}); + +describe("U12 rollback safety — flag OFF after flag ON keeps legacy behavior", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("a board built under flag-ON resolves identically and moves legacy-style under flag-OFF", async () => { + // Build a board under flag-ON. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const t = await store.createTask({ description: "rollback" }); + await store.moveTask(t.id, "todo", { moveSource: "user" }); + await store.moveTask(t.id, "in-progress", { moveSource: "user" }); + + // Flip the flag OFF. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + + // Legacy board intact: the task is still in in-progress. + expect((await store.getTask(t.id)).column).toBe("in-progress"); + + // Legacy engine behavior: an illegal move throws the legacy string (not a + // typed rejection), and a legal move works exactly as before. + const archived = await store.createTask({ description: "legacy" }); + await store.moveTask(archived.id, "todo", { moveSource: "user" }); + await store.moveTask(archived.id, "in-progress", { moveSource: "user" }); + await store.moveTask(archived.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + await store.moveTask(archived.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + await store.moveTask(archived.id, "archived", { moveSource: "user" }); + let caught: unknown; + try { + await store.moveTask(archived.id, "todo", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/Invalid transition/); + }); + + it("a card stranded in a custom column when the flag is toggled OFF degrades to a clean Invalid-transition error (no TypeError) and listTasks stays healthy", async () => { + // Flag ON: select a custom workflow whose entry column is custom, so the + // card is re-homed into a column that VALID_TRANSITIONS never keys. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const wf = await store.createWorkflowDefinition({ + name: "stranded", + ir: customIr("stranded", ["intake", "build", "ship"], "intake"), + }); + const task = await store.createTask({ description: "stranded card" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + expect((await store.getTask(task.id)).column).toBe("intake"); + + // Toggle the flag OFF — #1409: the ON→OFF evacuation re-homes the card from + // the custom "intake" column to the nearest legacy column (the default + // workflow's entry column, triage) so it is not stranded on the legacy path. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + expect((await store.getTask(task.id)).column).toBe("triage"); + + // listTasks stays healthy. + await expect(store.listTasks()).resolves.toBeDefined(); + + // The evacuated card now moves legacy-style: triage → todo works. + await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect((await store.getTask(task.id)).column).toBe("todo"); + }); +}); + +describe("Residual B: getBranchProgressByTask reads workflow_run_branches", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + function db(): { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } { + return (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + } + + it("returns an empty map when the table is empty (cheap short-circuit)", async () => { + const t = await store.createTask({ description: "x" }); + expect(store.getBranchProgressByTask([t.id]).size).toBe(0); + }); + + it("returns the latest run's branches for a task with rows", async () => { + const t = await store.createTask({ description: "fanout" }); + const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + // Older run (should be ignored). + db().prepare(ins).run(t.id, "run-1", "b1", "n1", "completed", "2026-06-01T00:00:00.000Z"); + // Latest run with two branches. + db().prepare(ins).run(t.id, "run-2", "b1", "n2", "running", "2026-06-03T00:00:00.000Z"); + db().prepare(ins).run(t.id, "run-2", "b2", "n3", "completed", "2026-06-03T00:00:01.000Z"); + + const byTask = store.getBranchProgressByTask([t.id]); + const entries = byTask.get(t.id) ?? []; + expect(entries.length).toBe(2); + expect(entries.map((e) => e.branchId).sort()).toEqual(["b1", "b2"]); + expect(entries.find((e) => e.branchId === "b2")?.status).toBe("completed"); + }); +}); + +describe("#1407/#1412/#1413: workflow_run_branches persistence + latest-run JOIN + prune", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + type BranchStore = { + saveWorkflowRunBranch(state: { + taskId: string; runId: string; branchId: string; currentNodeId: string; status: string; + }): void; + loadWorkflowRunBranches(taskId: string, runId: string): Array<{ + taskId: string; runId: string; branchId: string; currentNodeId: string; status: string; + }>; + clearWorkflowRunBranches(taskId: string, keepRunId: string): void; + }; + const bs = (): BranchStore => store as unknown as BranchStore; + + function rawCount(taskId: string): number { + const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; + const row = db + .prepare("SELECT COUNT(*) AS c FROM workflow_run_branches WHERE taskId = ?") + .get(taskId) as { c: number }; + return row.c; + } + + it("saveWorkflowRunBranch upserts one row per (taskId, runId, branchId) keyed by currentNodeId", async () => { + const t = await store.createTask({ description: "fanout" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n1", status: "running" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n2", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b2", currentNodeId: "n3", status: "running" }); + + // b1 overwrote in place (still one row), b2 added — 2 rows total. + expect(rawCount(t.id)).toBe(2); + const loaded = bs().loadWorkflowRunBranches(t.id, "r1"); + const b1 = loaded.find((s) => s.branchId === "b1"); + expect(b1?.currentNodeId).toBe("n2"); + expect(b1?.status).toBe("completed"); + }); + + it("loadWorkflowRunBranches returns only the requested run", async () => { + const t = await store.createTask({ description: "fanout" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n1", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r2", branchId: "b1", currentNodeId: "n9", status: "running" }); + expect(bs().loadWorkflowRunBranches(t.id, "r1").length).toBe(1); + expect(bs().loadWorkflowRunBranches(t.id, "r1")[0]?.currentNodeId).toBe("n1"); + }); + + it("clearWorkflowRunBranches prunes all runs except the kept one (#1412)", async () => { + const t = await store.createTask({ description: "fanout" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "old-1", branchId: "b1", currentNodeId: "n1", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "old-2", branchId: "b1", currentNodeId: "n1", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "keep", branchId: "b1", currentNodeId: "n5", status: "running" }); + expect(rawCount(t.id)).toBe(3); + + bs().clearWorkflowRunBranches(t.id, "keep"); + expect(rawCount(t.id)).toBe(1); + expect(bs().loadWorkflowRunBranches(t.id, "keep").length).toBe(1); + }); + + it("getBranchProgressByTask returns only the latest run's branches across multiple runs (#1413)", async () => { + const t = await store.createTask({ description: "fanout" }); + const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + // Older run. + db.prepare(ins).run(t.id, "run-1", "b1", "n1", "completed", "2026-06-01T00:00:00.000Z"); + db.prepare(ins).run(t.id, "run-1", "b2", "n2", "completed", "2026-06-01T00:00:01.000Z"); + // Latest run, two branches with staggered updatedAt (both must be returned). + db.prepare(ins).run(t.id, "run-2", "b1", "n3", "running", "2026-06-03T00:00:00.000Z"); + db.prepare(ins).run(t.id, "run-2", "b2", "n4", "completed", "2026-06-03T00:00:01.000Z"); + + const entries = store.getBranchProgressByTask([t.id]).get(t.id) ?? []; + expect(entries.length).toBe(2); + expect(entries.map((e) => e.nodeId).sort()).toEqual(["n3", "n4"]); + }); + + it("getBranchProgressByTask breaks updatedAt ties deterministically by runId (#1413)", async () => { + const t = await store.createTask({ description: "fanout" }); + const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + const ts = "2026-06-03T00:00:00.000Z"; + // Two runs with identical updatedAt — runId DESC ("run-b" > "run-a") wins. + db.prepare(ins).run(t.id, "run-a", "b1", "nA", "running", ts); + db.prepare(ins).run(t.id, "run-b", "b1", "nB", "running", ts); + + const entries = store.getBranchProgressByTask([t.id]).get(t.id) ?? []; + expect(entries.length).toBe(1); + expect(entries[0]?.nodeId).toBe("nB"); + }); +}); + +describe("U12 graduation report — parity drift is caught", () => { + it("transition-parity holds for the unmodified default workflow", () => { + expect(checkTransitionParity(BUILTIN_CODING_WORKFLOW_IR).agree).toBe(true); + }); + + it("a deliberately drifted default-workflow adjacency is caught by transition parity", () => { + // Clone the default IR and remove a legal edge target from in-progress's + // adjacency by dropping the "todo" backward column from its outgoing edges. + const drifted = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as WorkflowIr & { + edges: Array<{ from: string; to: string }>; + columns: Array<{ id: string }>; + }; + // Remove ALL columns named "archived" so the column set itself diverges — + // a coarse but unambiguous drift the gate must catch. + drifted.columns = drifted.columns.filter((c) => c.id !== "archived"); + const report = checkTransitionParity(drifted as unknown as WorkflowIr); + expect(report.agree).toBe(false); + expect(report.diffs.some((d) => d.from === "archived" || d.from === "done")).toBe(true); + }); + + it("graduation report is NOT ready with zero observations and is gated by every signal", () => { + const report = computeWorkflowColumnsGraduationReport({ + parity: { observed: 0, agreed: 0, drift: 0, agreeRate: 0, driftFieldCounts: {}, recentDrift: [] }, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents: [], + }); + expect(report.ready).toBe(false); + expect(report.blockers.some((b) => /observation window empty/.test(b))).toBe(true); + }); + + it("graduation report is ready only when parity clean, transitions match, and zero dual-accept disagreement", () => { + const report = computeWorkflowColumnsGraduationReport({ + parity: { observed: 100, agreed: 100, drift: 0, agreeRate: 1, driftFieldCounts: {}, recentDrift: [] }, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents: [], + }); + expect(report.transitionParity.agree).toBe(true); + expect(report.dualAccept.total).toBe(0); + expect(report.ready).toBe(true); + expect(report.blockers).toEqual([]); + }); + + it("dual-accept disagreements above zero block graduation", () => { + const events = [ + { + domain: "database", + mutationType: "merge:dependency-parity-diff", + target: "FN-1", + timestamp: "2026-06-03T00:00:00.000Z", + }, + { + domain: "database", + mutationType: "merge:lease-parity-diff", + target: "FN-2", + timestamp: "2026-06-03T00:00:01.000Z", + }, + ] as unknown as Parameters[0]; + const counted = countDualAcceptDisagreements(events); + expect(counted.total).toBe(2); + + const report = computeWorkflowColumnsGraduationReport({ + parity: { observed: 50, agreed: 50, drift: 0, agreeRate: 1, driftFieldCounts: {}, recentDrift: [] }, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents: events, + }); + expect(report.ready).toBe(false); + expect(report.blockers.some((b) => /dual-accept/.test(b))).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 8909b33c5f..7410346c67 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/move-task-characterization.test.ts b/packages/core/src/__tests__/move-task-characterization.test.ts new file mode 100644 index 0000000000..66c4f4901a --- /dev/null +++ b/packages/core/src/__tests__/move-task-characterization.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment node +// +// CHARACTERIZATION SUITE (U4 Execution Note — written FIRST, before any change +// to `moveTaskInternal`). +// +// This suite pins the CURRENT behavior of `moveTaskInternal` for every (from, +// to) pair in VALID_TRANSITIONS' domain and both moveSource values, plus the +// key column side effects: +// - merge-blocker on in-review → done (user source) +// - userPaused set only for user-source in-progress → todo +// - reopen field/step resets on in-review/done → todo|triage +// - autoMerge stamping on → in-review +// - timing fields (cumulativeActiveMs / executionStartedAt) on in-progress +// +// It runs GREEN against the unmodified store first, then runs forever against +// BOTH flag states (workflowColumns OFF and ON) — see the `flagStates` loop. +// Any divergence between the two flag states is a U4 parity FAILURE. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { VALID_TRANSITIONS } from "../types.js"; +import type { Column, Task } from "../types.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + +// Flag states the characterization runs against. OFF is the legacy path; ON is +// the workflow-resolved path. The default workflow MUST reproduce identical +// outcomes for both, so the same expectations apply. +const flagStates: Array<{ label: string; workflowColumns: boolean }> = [ + { label: "flag OFF (legacy path)", workflowColumns: false }, + { label: "flag ON (workflow-resolved default workflow)", workflowColumns: true }, +]; + +for (const flag of flagStates) { + describe(`moveTaskInternal characterization — ${flag.label}`, () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + if (flag.workflowColumns) { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + } + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + /** + * Drive a freshly-created task (starts in `triage`) into `column` using only + * legal, side-effect-tolerant moves. Returns the task. + */ + async function seedInColumn(column: Column): Promise { + const task = await store.createTask({ description: `seed-${column}` }); + switch (column) { + case "triage": + return task; + case "todo": + return store.moveTask(task.id, "todo", { moveSource: "user" }); + case "in-progress": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + return store.moveTask(task.id, "in-progress", { moveSource: "user" }); + case "in-review": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + return store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + case "done": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + return store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + case "archived": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + return store.moveTask(task.id, "archived", { moveSource: "user" }); + default: + throw new Error(`unhandled column ${column}`); + } + } + + describe("transition allow/reject matrix (every from×to×moveSource)", () => { + for (const from of ALL_COLUMNS) { + for (const to of ALL_COLUMNS) { + for (const moveSource of ["user", "engine"] as const) { + const allowed = from === to || VALID_TRANSITIONS[from].includes(to); + const label = `${from} → ${to} [${moveSource}] should ${allowed ? "ALLOW" : "REJECT"}`; + it(label, async () => { + const task = await seedInColumn(from); + // Same-column move is a no-op success in legacy behavior. + if (from === to) { + const result = await store.moveTask(task.id, to, { moveSource }); + expect(result.column).toBe(to); + return; + } + if (allowed) { + // in-review → done with merge-blocker only blocks for user source + // and only when a blocker exists; our seeded task has no blocker. + // Bare in-review targets bypass the handoff invariant via + // allowDirectInReviewMove, matching production drag behavior. + const opts = + to === "in-review" + ? { moveSource, allowDirectInReviewMove: true } + : { moveSource }; + const result = await store.moveTask(task.id, to, opts); + expect(result.column).toBe(to); + } else { + await expect( + store.moveTask(task.id, to, { moveSource }), + ).rejects.toThrow(/Invalid transition/); + } + }); + } + } + } + }); + + describe("merge-blocker side effect (in-review → done)", () => { + it("blocks a user move to done when a merge blocker exists", async () => { + const task = await seedInColumn("in-review"); + // Incomplete steps create a merge blocker (getTaskMergeBlocker). + await store.updateTask(task.id, { + steps: [{ name: "x", status: "pending" }] as Task["steps"], + }); + await expect( + store.moveTask(task.id, "done", { moveSource: "user" }), + ).rejects.toThrow(/Cannot move .* to done/); + }); + + it("skipMergeBlocker bypasses the blocker", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { + steps: [{ name: "x", status: "pending" }] as Task["steps"], + }); + const result = await store.moveTask(task.id, "done", { + moveSource: "engine", + skipMergeBlocker: true, + }); + expect(result.column).toBe("done"); + }); + }); + + describe("userPaused side effect (in-progress → todo)", () => { + it("sets userPaused for a user-source move", async () => { + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect(result.userPaused).toBe(true); + }); + + it("does NOT set userPaused for an engine-source move", async () => { + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "todo", { moveSource: "engine" }); + expect(result.userPaused).toBeUndefined(); + }); + }); + + describe("reopen resets (in-review → todo)", () => { + it("clears branch/summary/baseCommitSha on reopen to todo", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { + branch: "fusion/fn-x", + summary: "did stuff", + baseCommitSha: "abc123", + }); + const result = await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect(result.branch).toBeUndefined(); + expect(result.summary).toBeUndefined(); + expect(result.baseCommitSha).toBeUndefined(); + }); + }); + + describe("autoMerge stamping (→ in-review)", () => { + it("stamps autoMerge from settings when undefined", async () => { + await store.updateSettings({ autoMerge: true }); + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "in-review", { + moveSource: "user", + allowDirectInReviewMove: true, + }); + expect(result.autoMerge).toBe(true); + }); + }); + + describe("timing fields (→ in-progress)", () => { + it("sets executionStartedAt and initializes cumulativeActiveMs on entry", async () => { + const task = await seedInColumn("todo"); + const result = await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + expect(result.executionStartedAt).toBeTruthy(); + expect(result.cumulativeActiveMs).toBe(0); + }); + + it("accumulates cumulativeActiveMs on exit from in-progress", async () => { + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "in-review", { + moveSource: "user", + allowDirectInReviewMove: true, + }); + expect(result.cumulativeActiveMs).toBeGreaterThanOrEqual(0); + }); + }); + }); +} diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 855c54b1c2..3be684ea9a 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); }); }); }); diff --git a/packages/core/src/__tests__/step-parsers.test.ts b/packages/core/src/__tests__/step-parsers.test.ts new file mode 100644 index 0000000000..44d0f1d738 --- /dev/null +++ b/packages/core/src/__tests__/step-parsers.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import { + StepParserRegistry, + StepParserRegistrationError, + getStepParser, + listStepParsers, + registerStepParser, + unregisterStepParser, + parseStepHeadings, + parseJsonSteps, + __resetStepParserRegistryForTests, + type StepParser, +} from "../step-parsers.js"; + +describe("step-parsers registry (U12, KTD-12)", () => { + afterEach(() => { + __resetStepParserRegistryForTests(); + }); + + describe("step-headings built-in (byte-identical to legacy)", () => { + const headings = () => getStepParser("step-headings")!; + + it("is registered as a built-in", () => { + expect(getStepParser("step-headings")).toBeDefined(); + expect(listStepParsers().map((p) => p.id)).toContain("step-headings"); + }); + + it("parses unannotated headings byte-identically to the legacy regex", () => { + const content = `## Steps + +### Step 0: Preflight + +- [ ] x + +### Step 1: Implementation + +### Step 2: Testing +`; + expect(headings().parse(content).steps).toEqual([ + { name: "Preflight" }, + { name: "Implementation" }, + { name: "Testing" }, + ]); + }); + + it("matches the legacy regex output exactly for varied unannotated headings", () => { + const content = [ + "### Step 0: A", + "### Step 12: Multi word title", + "### Step 3 — dash but no annotation: Real Name", + "### Step 4: trailing spaces here ", + "### Step 5 no colon at all", + "not a step heading: ignored", + ].join("\n"); + const legacy: { name: string }[] = []; + const re = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + legacy.push({ name: m[1].trim() }); + } + expect(headings().parse(content).steps).toEqual(legacy); + }); + + it("parses (depends: 1,2) into 0-indexed dependsOn", () => { + expect(headings().parse("### Step 3 (depends: 1,2): Title").steps).toEqual([ + { name: "Title", dependsOn: [0, 1] }, + ]); + }); + + it("dedupes and sorts depends values", () => { + expect(headings().parse("### Step 5 (depends: 3,1,3,2): T").steps).toEqual([ + { name: "T", dependsOn: [0, 1, 2] }, + ]); + }); + + it("empty depends list yields no dependsOn", () => { + expect(headings().parse("### Step 2 (depends: ): T").steps).toEqual([ + { name: "T" }, + ]); + }); + + it("falls back deterministically on a malformed depends annotation", () => { + expect(headings().parse("### Step 1 (depends: bad): Real Title").steps).toEqual([ + { name: "Real Title" }, + ]); + }); + + it("falls back deterministically when the annotation has no closing paren", () => { + expect(headings().parse("### Step 1 (depends: 1,2 oops: Title").steps).toEqual([ + { name: "1,2 oops: Title" }, + ]); + }); + + it("the extracted parseStepHeadings still yields TaskStep[] with status", () => { + // The store-facing function keeps the `status: "pending"` field. + expect(parseStepHeadings("### Step 0: Preflight")).toEqual([ + { name: "Preflight", status: "pending" }, + ]); + }); + }); + + describe("json-steps built-in", () => { + const json = () => getStepParser("json-steps")!; + + it("is registered as a built-in", () => { + expect(getStepParser("json-steps")).toBeDefined(); + }); + + it("parses a happy-path array of {name, depends}", () => { + const content = JSON.stringify([ + { name: "Plan" }, + { name: "Implement", depends: [1] }, + { name: "Test", depends: [1, 2] }, + ]); + expect(json().parse(content).steps).toEqual([ + { name: "Plan" }, + { name: "Implement", dependsOn: [0] }, + { name: "Test", dependsOn: [0, 1] }, + ]); + }); + + it("converts 1-indexed depends to 0-indexed dependsOn, deduped and sorted", () => { + const content = JSON.stringify([{ name: "X", depends: [3, 1, 3, 2] }]); + expect(json().parse(content).steps).toEqual([ + { name: "X", dependsOn: [0, 1, 2] }, + ]); + }); + + it("trims names and omits dependsOn when depends is empty", () => { + const content = JSON.stringify([{ name: " Spaced ", depends: [] }]); + expect(json().parse(content).steps).toEqual([{ name: "Spaced" }]); + }); + + it("parseJsonSteps is exported directly and matches the registry parser", () => { + const content = JSON.stringify([{ name: "A" }]); + expect(parseJsonSteps(content)).toEqual(json().parse(content)); + }); + + it("throws a descriptive error on non-JSON input", () => { + expect(() => json().parse("not json {")).toThrow(/not valid JSON/); + }); + + it("throws when the document is not an array", () => { + expect(() => json().parse(JSON.stringify({ name: "X" }))).toThrow( + /must be a JSON array/, + ); + }); + + it("throws when a step is missing its name", () => { + expect(() => json().parse(JSON.stringify([{ foo: "bar" }]))).toThrow( + /index 0 must have a non-empty string 'name'/, + ); + }); + + it("throws when a step name is blank", () => { + expect(() => json().parse(JSON.stringify([{ name: " " }]))).toThrow( + /non-empty string 'name'/, + ); + }); + + it("throws when depends is not an array", () => { + expect(() => + json().parse(JSON.stringify([{ name: "X", depends: 1 }])), + ).toThrow(/'depends' must be an array/); + }); + + it("throws when depends contains a non-positive-integer", () => { + expect(() => + json().parse(JSON.stringify([{ name: "X", depends: [0] }])), + ).toThrow(/positive integers/); + expect(() => + json().parse(JSON.stringify([{ name: "X", depends: ["1"] }])), + ).toThrow(/positive integers/); + }); + + it("throws when an entry is not an object", () => { + expect(() => json().parse(JSON.stringify(["just a string"]))).toThrow( + /index 0 must be an object/, + ); + }); + }); + + describe("registry semantics", () => { + it("rejects overwriting a built-in with a non-builtin id", () => { + const reg = new StepParserRegistry(); + reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }, { builtin: true }); + expect(() => + reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }), + ).toThrowError(StepParserRegistrationError); + try { + reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }); + } catch (e) { + expect((e as StepParserRegistrationError).reason).toBe( + "builtin-namespace-protected", + ); + } + }); + + it("rejects a duplicate registration", () => { + const reg = new StepParserRegistry(); + const parser: StepParser = { + id: "plugin:acme:custom", + parse: () => ({ steps: [] }), + }; + reg.register(parser); + expect(() => reg.register(parser)).toThrowError(StepParserRegistrationError); + }); + + it("enforces the plugin id shape for non-builtins", () => { + const reg = new StepParserRegistry(); + const bad = ["custom", "plugin:acme", "plugin::custom", "plugin:Acme:Custom", "other:acme:custom"]; + for (const id of bad) { + expect(() => reg.register({ id, parse: () => ({ steps: [] }) })).toThrowError( + StepParserRegistrationError, + ); + } + // A well-formed namespaced id is accepted. + expect(() => + reg.register({ id: "plugin:acme:custom", parse: () => ({ steps: [] }) }), + ).not.toThrow(); + }); + + it("allows a built-in to use a non-namespaced id", () => { + const reg = new StepParserRegistry(); + expect(() => + reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }, { builtin: true }), + ).not.toThrow(); + }); + + it("rejects an invalid definition (no id / no parse)", () => { + const reg = new StepParserRegistry(); + expect(() => reg.register({ id: "", parse: () => ({ steps: [] }) })).toThrowError( + StepParserRegistrationError, + ); + expect(() => + reg.register({ id: "plugin:acme:x" } as unknown as StepParser), + ).toThrowError(StepParserRegistrationError); + }); + + it("round-trips register/unregister for a plugin parser via the shared API", () => { + const id = "plugin:acme:json2"; + expect(getStepParser(id)).toBeUndefined(); + registerStepParser({ id, parse: () => ({ steps: [{ name: "ok" }] }) }); + expect(getStepParser(id)?.parse("").steps).toEqual([{ name: "ok" }]); + expect(unregisterStepParser(id)).toBe(true); + expect(getStepParser(id)).toBeUndefined(); + // Unregistering again (or a missing id) is a no-op false. + expect(unregisterStepParser(id)).toBe(false); + }); + + it("never unregisters a built-in", () => { + const reg = new StepParserRegistry(); + reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }, { builtin: true }); + expect(reg.unregister("step-headings")).toBe(false); + expect(reg.has("step-headings")).toBe(true); + }); + + it("getStepParser returns undefined for an unknown id", () => { + expect(getStepParser("nope")).toBeUndefined(); + expect(getStepParser("plugin:acme:absent")).toBeUndefined(); + }); + }); + + describe("parseStepsFromPrompt-through-registry parity (KTD-12)", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + const FIXTURES = [ + `## Steps + +### Step 0: Preflight + +### Step 1: Implementation + +### Step 2: Testing +`, + `# Task + +## Steps + +### Step 1: First + +### Step 2 (depends: 1): Second + +### Step 3 (depends: 1,2): Third +`, + `### Step 1 (depends: bad): Real Title`, + ]; + + it("store path equals the direct step-headings parser on the same content", async () => { + const store = harness.store(); + const rootDir = harness.rootDir(); + for (const content of FIXTURES) { + const task = await store.createTask({ description: "parity" }); + const dir = join(rootDir, ".fusion", "tasks", task.id); + await writeFile(join(dir, "PROMPT.md"), content); + + const viaStore = await store.parseStepsFromPrompt(task.id); + // Direct parser yields { name, dependsOn? }; the store path re-applies + // the `pending` status. Reconstruct the expected store shape from the + // direct parse to assert identical behavior through both paths. + const direct = parseStepHeadings(content); + expect(viaStore).toEqual(direct); + } + }); + }); +}); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 646438bbec..6c09641156 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(105); + expect(store.getDatabase().getSchemaVersion()).toBe(108); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index cea876e75b..6f407c9793 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -6,7 +6,7 @@ import { existsSync } from "node:fs"; import * as projectMemory from "../project-memory.js"; import { AgentStore } from "../agent-store.js"; import { CentralDatabase } from "../central-db.js"; -import { InvalidFileScopeError, isValidFileScopeEntry, TaskStore, TaskHasDependentsError } from "../store.js"; +import { InvalidFileScopeError, isValidFileScopeEntry, parseStepHeadings, TaskStore, TaskHasDependentsError } from "../store.js"; import { buildResearchDocumentKey, type Task } from "../types.js"; import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; @@ -41,6 +41,104 @@ describe("TaskStore", () => { const steps = await store.parseStepsFromPrompt(task.id); expect(steps).toEqual([]); }); + + it("parses depends annotations from PROMPT.md (1-indexed → 0-indexed)", async () => { + const task = await store.createTask({ description: "Task with depends" }); + const dir = join(rootDir, ".fusion", "tasks", task.id); + await writeFile( + join(dir, "PROMPT.md"), + `# ${task.id}: Task + +## Steps + +### Step 1: First + +### Step 2 (depends: 1): Second + +### Step 3 (depends: 1,2): Third +`, + ); + const steps = await store.parseStepsFromPrompt(task.id); + expect(steps).toEqual([ + { name: "First", status: "pending" }, + { name: "Second", status: "pending", dependsOn: [0] }, + { name: "Third", status: "pending", dependsOn: [0, 1] }, + ]); + }); + }); + + describe("parseStepHeadings (step-inversion U1)", () => { + it("parses unannotated headings byte-identically to the legacy regex", () => { + const content = `## Steps + +### Step 0: Preflight + +- [ ] x + +### Step 1: Implementation + +### Step 2: Testing +`; + // The legacy behavior: name = text after the first colon, trimmed; no dependsOn. + expect(parseStepHeadings(content)).toEqual([ + { name: "Preflight", status: "pending" }, + { name: "Implementation", status: "pending" }, + { name: "Testing", status: "pending" }, + ]); + }); + + it("matches the legacy regex output exactly for varied unannotated headings", () => { + const content = [ + "### Step 0: A", + "### Step 12: Multi word title", + "### Step 3 — dash but no annotation: Real Name", + "### Step 4: trailing spaces here ", + "### Step 5 no colon at all", + "not a step heading: ignored", + ].join("\n"); + // Reference: the original regex. + const legacy: { name: string; status: "pending" }[] = []; + const re = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + legacy.push({ name: m[1].trim(), status: "pending" }); + } + expect(parseStepHeadings(content)).toEqual(legacy); + }); + + it("parses (depends: 1,2) into 0-indexed dependsOn", () => { + expect(parseStepHeadings("### Step 3 (depends: 1,2): Title")).toEqual([ + { name: "Title", status: "pending", dependsOn: [0, 1] }, + ]); + }); + + it("dedupes and sorts depends values", () => { + expect(parseStepHeadings("### Step 5 (depends: 3,1,3,2): T")).toEqual([ + { name: "T", status: "pending", dependsOn: [0, 1, 2] }, + ]); + }); + + it("empty depends list yields no dependsOn", () => { + expect(parseStepHeadings("### Step 2 (depends: ): T")).toEqual([ + { name: "T", status: "pending" }, + ]); + }); + + it("falls back deterministically on a malformed depends annotation (name after colon following the paren)", () => { + // 'bad' is not a positive integer → fallback: name starts after the colon + // following the closing paren. + expect(parseStepHeadings("### Step 1 (depends: bad): Real Title")).toEqual([ + { name: "Real Title", status: "pending" }, + ]); + }); + + it("falls back deterministically when the annotation has no closing paren", () => { + // No closing paren → name starts after the FIRST colon (inside `depends:`), + // per the documented deterministic fallback. + expect(parseStepHeadings("### Step 1 (depends: 1,2 oops: Title")).toEqual([ + { name: "1,2 oops: Title", status: "pending" }, + ]); + }); }); diff --git a/packages/core/src/__tests__/store-update-step-order.test.ts b/packages/core/src/__tests__/store-update-step-order.test.ts index adde221985..42117abdd8 100644 --- a/packages/core/src/__tests__/store-update-step-order.test.ts +++ b/packages/core/src/__tests__/store-update-step-order.test.ts @@ -54,4 +54,81 @@ describe("TaskStore.updateStep step-order guard", () => { expect(updated.steps[0].status).toBe("done"); expect(updated.log.some((entry) => entry.action.includes("Ignored done→in-progress regression"))).toBe(true); }); + + // ── U6: graph-source projection discipline (KTD-7/KTD-11) ────────────────── + + it("graph source: done is legal in dependency order even when an earlier step is pending", async () => { + // Step 2 depends only on the previous step (1) by default. With step 1 done, + // step 2 may go done under graph source even though step 0 is still pending — + // the legacy strict-index-order guard relaxes to dependency order. + const store = harness.store(); + const task = await harness.createTaskWithSteps(); + // Prime the step list, then give step 2 an explicit dependency on step 0 only + // (skipping step 1), so step 2 may go done with step 1 still pending. + await store.updateStep(task.id, 0, "in-progress"); + const primed = await store.getTask(task.id); + const steps = primed.steps.map((s, i) => (i === 2 ? { ...s, dependsOn: [0] } : { ...s })); + await store.updateTask(task.id, { steps }); + + await store.updateStep(task.id, 0, "done", { source: "graph" }); + const updated = await store.updateStep(task.id, 2, "done", { source: "graph" }); + + expect(updated.steps[2].status).toBe("done"); + // Step 1 was never touched and remains pending — strict index order would have + // suppressed the step-2 done write. + expect(updated.steps[1].status).toBe("pending"); + }); + + it("graph source: out-of-order done (unmet dependency) is suppressed AND audited loudly", async () => { + // Step 1's default dependency is step 0, which is still pending → suppressed. + const store = harness.store(); + const task = await harness.createTaskWithSteps(); + // Prime the step list (graph source bypasses PROMPT.md auto-init). + await store.updateStep(task.id, 1, "in-progress"); + + const updated = await store.updateStep(task.id, 1, "done", { source: "graph" }); + + // Suppressed: step 1's default dependency (step 0) is still pending, so the + // done write is rejected and step 1 keeps its prior (non-done) status. + expect(updated.steps[1].status).not.toBe("done"); + expect( + updated.log.some((e) => e.action.includes("Ignored dependency-order done for step 1")), + ).toBe(true); + // Graph suppression is surfaced loudly (not the legacy silent ignore). + expect( + updated.log.some((e) => e.action.includes("[integrity-warning] graph-source updateStep suppressed")), + ).toBe(true); + }); + + it("legacy source: silent out-of-order ignore behavior is unchanged (no integrity-warning)", async () => { + const store = harness.store(); + const task = await harness.createTaskWithSteps(); + + await store.updateStep(task.id, 0, "done"); + const updated = await store.updateStep(task.id, 2, "done"); // legacy, no source + + expect(updated.steps[2].status).toBe("pending"); + expect(updated.log.some((e) => e.action.includes("Ignored out-of-order done for step 2"))).toBe(true); + // Legacy stays silent — no integrity-warning emitted. + expect(updated.log.some((e) => e.action.includes("[integrity-warning]"))).toBe(false); + }); + + it("graph source: auto-reinit from PROMPT.md is bypassed (explicit indices only)", async () => { + // A fresh task with no JSON steps would, under legacy semantics, parse steps + // from PROMPT.md on the first updateStep. Graph source bypasses that — so an + // index into an unparsed (empty) step list is out of range and rejects. + const store = harness.store(); + const task = await store.createTask({ description: "graph reinit bypass" }); + // No PROMPT.md steps are written; task.steps starts empty. + + await expect(store.updateStep(task.id, 0, "in-progress", { source: "graph" })).rejects.toThrow( + /out of range/, + ); + + // Legacy path on the same empty task would attempt the PROMPT.md reinit + // instead of bypassing — proving the divergence is graph-source-only. (Here + // there is no PROMPT.md either, so legacy also has zero steps and rejects, + // but via the auto-init path rather than the bypass.) + await expect(store.updateStep(task.id, 0, "in-progress")).rejects.toThrow(/out of range/); + }); }); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 6e442a3f1b..7352ec08bf 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(108); const index = db .prepare( diff --git a/packages/core/src/__tests__/task-fields.test.ts b/packages/core/src/__tests__/task-fields.test.ts new file mode 100644 index 0000000000..5306ad9547 --- /dev/null +++ b/packages/core/src/__tests__/task-fields.test.ts @@ -0,0 +1,530 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { + validateCustomFieldPatch, + applyFieldDefaults, + reconcileFieldsOnWorkflowChange, +} from "../task-fields.js"; +import type { WorkflowFieldDefinition, WorkflowIr } from "../workflow-ir-types.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +/** + * U11 / KTD-13 — custom task fields: validation authority, defaults, + * reconciliation, and the store-level write authority. + * + * The pure functions in task-fields.ts are the single validation core; the + * store delegates to them for updateTask/updateTaskCustomFields and for + * workflow-switch / definition-edit reconciliation. These tests cover both. + */ + +// ── Field-definition fixtures ──────────────────────────────────────────────── + +const F = (over: Partial & { id: string; type: WorkflowFieldDefinition["type"] }): WorkflowFieldDefinition => ({ + name: over.id, + ...over, +}); + +const enumOpts = [ + { value: "high", label: "High" }, + { value: "low", label: "Low" }, +]; + +const ALL_TYPES: WorkflowFieldDefinition[] = [ + F({ id: "s", type: "string" }), + F({ id: "tx", type: "text" }), + F({ id: "n", type: "number" }), + F({ id: "b", type: "boolean" }), + F({ id: "e", type: "enum", options: enumOpts }), + F({ id: "m", type: "multi-enum", options: enumOpts }), + F({ id: "d", type: "date" }), + F({ id: "u", type: "url" }), +]; + +// ── Pure validation: every type ────────────────────────────────────────────── + +describe("validateCustomFieldPatch — per-type validate/reject", () => { + it("string/text accept strings, reject non-strings", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { s: "hi", tx: "yo" }).ok).toBe(true); + const r = validateCustomFieldPatch(ALL_TYPES, { s: 5 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("type-mismatch"); + }); + + it("number accepts finite numbers, rejects NaN/Infinity/non-number", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { n: 3 }).ok).toBe(true); + expect(validateCustomFieldPatch(ALL_TYPES, { n: 0 }).ok).toBe(true); + expect(validateCustomFieldPatch(ALL_TYPES, { n: Number.NaN }).ok).toBe(false); + expect(validateCustomFieldPatch(ALL_TYPES, { n: Number.POSITIVE_INFINITY }).ok).toBe(false); + expect(validateCustomFieldPatch(ALL_TYPES, { n: "3" }).ok).toBe(false); + }); + + it("boolean accepts booleans only", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { b: true }).ok).toBe(true); + expect(validateCustomFieldPatch(ALL_TYPES, { b: "true" }).ok).toBe(false); + }); + + it("date accepts parseable ISO strings, rejects garbage", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { d: "2026-06-04" }).ok).toBe(true); + expect(validateCustomFieldPatch(ALL_TYPES, { d: "2026-06-04T12:00:00Z" }).ok).toBe(true); + expect(validateCustomFieldPatch(ALL_TYPES, { d: "not-a-date" }).ok).toBe(false); + expect(validateCustomFieldPatch(ALL_TYPES, { d: 20260604 }).ok).toBe(false); + }); + + it("url accepts URL-parseable strings, rejects bad", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { u: "https://example.com/x" }).ok).toBe(true); + const r = validateCustomFieldPatch(ALL_TYPES, { u: "not a url" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("type-mismatch"); + }); +}); + +describe("validateCustomFieldPatch — enum membership", () => { + it("accepts a declared option, rejects a non-member with enum-violation", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { e: "high" }).ok).toBe(true); + const r = validateCustomFieldPatch(ALL_TYPES, { e: "medium" }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.rejection.code).toBe("enum-violation"); + expect(r.rejection.fieldId).toBe("e"); + } + }); + it("rejects a non-string enum value with type-mismatch", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { e: 1 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("type-mismatch"); + }); +}); + +describe("validateCustomFieldPatch — multi-enum subsets + dupes", () => { + it("accepts a subset of options", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { m: ["high"] }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized.m).toEqual(["high"]); + }); + it("accepts the empty array", () => { + expect(validateCustomFieldPatch(ALL_TYPES, { m: [] }).ok).toBe(true); + }); + it("rejects a non-member with enum-violation", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { m: ["high", "medium"] }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("enum-violation"); + }); + it("rejects duplicate members", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { m: ["high", "high"] }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("enum-violation"); + }); + it("rejects a non-array", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { m: "high" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("type-mismatch"); + }); +}); + +describe("validateCustomFieldPatch — unknown field & no-fields", () => { + it("rejects a patch key naming no declared field", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { nope: 1 }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.rejection.code).toBe("unknown-field"); + expect(r.rejection.fieldId).toBe("nope"); + } + }); + it("rejects any non-empty patch when no fields are defined (no-fields-defined)", () => { + const r = validateCustomFieldPatch(undefined, { anything: 1 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.rejection.code).toBe("no-fields-defined"); + const r2 = validateCustomFieldPatch([], { x: 1 }); + expect(r2.ok).toBe(false); + if (!r2.ok) expect(r2.rejection.code).toBe("no-fields-defined"); + }); + it("accepts an EMPTY patch even with no fields defined", () => { + expect(validateCustomFieldPatch(undefined, {}).ok).toBe(true); + expect(validateCustomFieldPatch([], {}).ok).toBe(true); + }); + it("treats null/undefined patch values as delete sentinels (normalized to null)", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { s: null, n: undefined }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toEqual({ s: null, n: null }); + }); +}); + +// ── Defaults ────────────────────────────────────────────────────────────── + +describe("applyFieldDefaults", () => { + const fields: WorkflowFieldDefinition[] = [ + F({ id: "req", type: "string", required: true, default: "x" }), + F({ id: "reqNoDefault", type: "string", required: true }), + F({ id: "optDefault", type: "number", default: 7 }), + ]; + it("fills required field defaults absent from current", () => { + expect(applyFieldDefaults(fields, {})).toEqual({ req: "x" }); + }); + it("does not override an existing value", () => { + expect(applyFieldDefaults(fields, { req: "kept" })).toEqual({ req: "kept" }); + }); + it("ignores non-required defaults and required-without-default", () => { + const out = applyFieldDefaults(fields, {}); + expect(out).not.toHaveProperty("optDefault"); + expect(out).not.toHaveProperty("reqNoDefault"); + }); +}); + +// ── Reconciliation ────────────────────────────────────────────────────────── + +describe("reconcileFieldsOnWorkflowChange", () => { + it("keeps same-id type-compatible values, orphans removed ids", () => { + const oldF = [F({ id: "a", type: "string" }), F({ id: "gone", type: "number" })]; + const newF = [F({ id: "a", type: "string" })]; + const { kept, orphaned } = reconcileFieldsOnWorkflowChange(oldF, newF, { a: "v", gone: 1 }); + expect(kept).toEqual({ a: "v" }); + expect(orphaned).toEqual({ gone: 1 }); + }); + + it("orphans a value when the new type is incompatible", () => { + const oldF = [F({ id: "a", type: "string" })]; + const newF = [F({ id: "a", type: "number" })]; + const { kept, orphaned } = reconcileFieldsOnWorkflowChange(oldF, newF, { a: "still-a-string" }); + expect(kept).toEqual({}); + expect(orphaned).toEqual({ a: "still-a-string" }); + }); + + it("keeps an enum value still in the new options, orphans one no longer present", () => { + const oldF = [F({ id: "e", type: "enum", options: enumOpts })]; + const newF = [F({ id: "e", type: "enum", options: [{ value: "high", label: "H" }] })]; + expect(reconcileFieldsOnWorkflowChange(oldF, newF, { e: "high" }).kept).toEqual({ e: "high" }); + expect(reconcileFieldsOnWorkflowChange(oldF, newF, { e: "low" }).orphaned).toEqual({ e: "low" }); + }); +}); + +// ── Store authority integration ────────────────────────────────────────────── + +describe("store: updateTaskCustomFields + updateTask integration (U11)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + const irWith = (fields: WorkflowFieldDefinition[], name = "wf"): WorkflowIr => + ({ + version: "v2", + name, + columns: [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "done", name: "done", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + fields, + }) as unknown as WorkflowIr; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function taskWithFields(fields: WorkflowFieldDefinition[]) { + const def = await (store as any).createWorkflowDefinition({ name: "WF", ir: irWith(fields) }); + const t = await store.createTask({ description: "field task" }); + await (store as any).selectTaskWorkflow(t.id, def.id); + return { task: t, workflowId: def.id as string }; + } + + it("happy path: validates, merges, persists, returns ok", async () => { + const { task } = await taskWithFields([ + F({ id: "sev", type: "enum", options: enumOpts }), + F({ id: "pts", type: "number" }), + ]); + const r = await (store as any).updateTaskCustomFields(task.id, { sev: "high", pts: 5 }); + expect(r.ok).toBe(true); + const got = await store.getTask(task.id); + expect(got?.customFields).toEqual({ sev: "high", pts: 5 }); + }); + + it("reject path: returns a typed rejection, does not mutate", async () => { + const { task } = await taskWithFields([F({ id: "pts", type: "number" })]); + const r = await (store as any).updateTaskCustomFields(task.id, { pts: "not-a-number" }); + expect(r.ok).toBe(false); + expect(r.rejection.code).toBe("type-mismatch"); + expect(r.rejection.fieldId).toBe("pts"); + const got = await store.getTask(task.id); + expect(got?.customFields).toEqual({}); + }); + + it("unknown-field rejection on an undeclared key", async () => { + const { task } = await taskWithFields([F({ id: "pts", type: "number" })]); + const r = await (store as any).updateTaskCustomFields(task.id, { nope: 1 }); + expect(r.ok).toBe(false); + expect(r.rejection.code).toBe("unknown-field"); + }); + + it("default workflow (zero fields) rejects cleanly with no-fields-defined", async () => { + const t = await store.createTask({ description: "default wf" }); + const r = await (store as any).updateTaskCustomFields(t.id, { anything: 1 }); + expect(r.ok).toBe(false); + expect(r.rejection.code).toBe("no-fields-defined"); + }); + + it("emits task:updated on a successful write", async () => { + const { task } = await taskWithFields([F({ id: "pts", type: "number" })]); + let emitted = 0; + (store as any).on("task:updated", () => { + emitted += 1; + }); + const r = await (store as any).updateTaskCustomFields(task.id, { pts: 1 }); + expect(r.ok).toBe(true); + expect(emitted).toBeGreaterThanOrEqual(1); + }); + + it("null patch value deletes the stored value", async () => { + const { task } = await taskWithFields([F({ id: "pts", type: "number" }), F({ id: "x", type: "number" })]); + await (store as any).updateTaskCustomFields(task.id, { pts: 1, x: 2 }); + await (store as any).updateTaskCustomFields(task.id, { pts: null }); + const got = await store.getTask(task.id); + expect(got?.customFields).toEqual({ x: 2 }); + }); + + it("updateTask with an invalid customFields patch throws CustomFieldRejectionError", async () => { + const { task } = await taskWithFields([F({ id: "pts", type: "number" })]); + await expect(store.updateTask(task.id, { customFields: { pts: "bad" } })).rejects.toThrow(/pts/); + }); + + it("applies required+default fields at workflow selection", async () => { + const def = await (store as any).createWorkflowDefinition({ + name: "Defaults", + ir: irWith([F({ id: "tier", type: "string", required: true, default: "bronze" })]), + }); + const t = await store.createTask({ description: "defaults" }); + await (store as any).selectTaskWorkflow(t.id, def.id); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({ tier: "bronze" }); + }); +}); + +describe("store: workflow switch reconciliation (U11)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + const irWith = (fields: WorkflowFieldDefinition[], name: string): WorkflowIr => + ({ + version: "v2", + name, + columns: [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "done", name: "done", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + fields, + }) as unknown as WorkflowIr; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("keeps same-id compatible values and orphans the rest (orphan-not-delete)", async () => { + const wfA = await (store as any).createWorkflowDefinition({ + name: "A", + ir: irWith([F({ id: "shared", type: "string" }), F({ id: "onlyA", type: "number" })], "A"), + }); + const wfB = await (store as any).createWorkflowDefinition({ + name: "B", + ir: irWith([F({ id: "shared", type: "string" }), F({ id: "onlyB", type: "boolean" })], "B"), + }); + const t = await store.createTask({ description: "switch" }); + await (store as any).selectTaskWorkflow(t.id, wfA.id); + await (store as any).updateTaskCustomFields(t.id, { shared: "v", onlyA: 3 }); + + await (store as any).selectTaskWorkflow(t.id, wfB.id); + const got = await store.getTask(t.id); + // shared kept; onlyA orphaned but RETAINED in storage (never destroyed). + expect(got?.customFields).toEqual({ shared: "v", onlyA: 3 }); + }); +}); + +describe("store: updateWorkflowDefinition field-type change coercion (U11)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + const irWith = (fields: WorkflowFieldDefinition[], name = "WF"): WorkflowIr => + ({ + version: "v2", + name, + columns: [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "done", name: "done", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + fields, + }) as unknown as WorkflowIr; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function fieldedTaskAndWf(fields: WorkflowFieldDefinition[]) { + const def = await (store as any).createWorkflowDefinition({ name: "WF", ir: irWith(fields) }); + const t = await store.createTask({ description: "edit" }); + await (store as any).selectTaskWorkflow(t.id, def.id); + return { workflowId: def.id as string, taskId: t.id as string }; + } + + it("rejects an incompatible type change with occupants and no coerce", async () => { + const { workflowId, taskId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]); + await (store as any).updateTaskCustomFields(taskId, { x: "hello" }); + await expect( + store.updateWorkflowDefinition(workflowId, { ir: irWith([F({ id: "x", type: "number" })]) }), + ).rejects.toThrow(/IncompatibleFieldChange|incompatibl/i); + }); + + it("coerce:keep-orphaned retains the now-incompatible value", async () => { + const { workflowId, taskId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]); + await (store as any).updateTaskCustomFields(taskId, { x: "hello" }); + await store.updateWorkflowDefinition(workflowId, { + ir: irWith([F({ id: "x", type: "number" })]), + coerce: "keep-orphaned", + }); + const got = await store.getTask(taskId); + expect(got?.customFields).toEqual({ x: "hello" }); + }); + + it("coerce:drop discards the now-incompatible value", async () => { + const { workflowId, taskId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]); + await (store as any).updateTaskCustomFields(taskId, { x: "hello" }); + await store.updateWorkflowDefinition(workflowId, { + ir: irWith([F({ id: "x", type: "number" })]), + coerce: "drop", + }); + const got = await store.getTask(taskId); + expect(got?.customFields).toEqual({}); + }); + + it("removing a field outright orphans (never blocks, value retained)", async () => { + const { workflowId, taskId } = await fieldedTaskAndWf([ + F({ id: "x", type: "string" }), + F({ id: "y", type: "string" }), + ]); + await (store as any).updateTaskCustomFields(taskId, { x: "a", y: "b" }); + await store.updateWorkflowDefinition(workflowId, { ir: irWith([F({ id: "x", type: "string" })]) }); + const got = await store.getTask(taskId); + // y orphaned but retained. + expect(got?.customFields).toEqual({ x: "a", y: "b" }); + }); + + // T1 (store.ts:12410): a field-schema edit that adds a new required+default + // field must backfill the default onto EVERY occupant, including occupants + // that currently hold no custom field values — not only ones already populated. + it("backfills a new required+default field onto occupants with no existing values", async () => { + const { taskId, workflowId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]); + // Occupant deliberately has NO custom field values stored. + const before = await store.getTask(taskId); + expect(before?.customFields ?? {}).toEqual({}); + + await store.updateWorkflowDefinition(workflowId, { + ir: irWith([ + F({ id: "x", type: "string" }), + F({ id: "tier", type: "string", required: true, default: "bronze" }), + ]), + }); + + const got = await store.getTask(taskId); + expect(got?.customFields).toEqual({ tier: "bronze" }); + }); +}); + +// ── Archive → unarchive customFields round-trip ────────────────────────────── + +describe("store: archive → unarchive preserves customFields (T0)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + const irWith = (fields: WorkflowFieldDefinition[], name = "WF"): WorkflowIr => + ({ + version: "v2", + name, + columns: [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "done", name: "done", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + fields, + }) as unknown as WorkflowIr; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("restores customFields after an archive → unarchive round-trip", async () => { + const def = await (store as any).createWorkflowDefinition({ + name: "WF", + ir: irWith([F({ id: "sev", type: "enum", options: enumOpts }), F({ id: "pts", type: "number" })]), + }); + const t = await store.createTask({ description: "round-trip" }); + await (store as any).selectTaskWorkflow(t.id, def.id); + await (store as any).updateTaskCustomFields(t.id, { sev: "high", pts: 5 }); + + // Move through the legacy transition chain to reach 'done', then archive. + await store.moveTask(t.id, "todo"); + await store.moveTask(t.id, "in-progress"); + await store.moveTask(t.id, "in-review"); + await store.moveTask(t.id, "done"); + const archived = await store.archiveTask(t.id); + expect(archived.column).toBe("archived"); + + const restored = await store.unarchiveTask(t.id); + expect(restored.customFields).toEqual({ sev: "high", pts: 5 }); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({ sev: "high", pts: 5 }); + }); +}); + +// ── JSON round-trip stability ──────────────────────────────────────────────── + +describe("custom-field values JSON round-trip", () => { + it("normalized values survive a JSON round-trip unchanged", () => { + const r = validateCustomFieldPatch(ALL_TYPES, { + s: "x", + n: 1.5, + b: false, + e: "low", + m: ["high", "low"], + d: "2026-06-04", + u: "https://x.test/", + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(JSON.parse(JSON.stringify(r.normalized))).toEqual(r.normalized); + } + }); +}); diff --git a/packages/core/src/__tests__/trait-registry.test.ts b/packages/core/src/__tests__/trait-registry.test.ts new file mode 100644 index 0000000000..ce36ccd15e --- /dev/null +++ b/packages/core/src/__tests__/trait-registry.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + TraitRegistry, + TraitRegistrationError, +} from "../trait-registry.js"; +import type { TraitDefinition } from "../trait-types.js"; +import type { WorkflowIrColumn } from "../workflow-ir-types.js"; + +function col(id: string, traits: string[]): WorkflowIrColumn { + return { id, name: id, traits: traits.map((t) => ({ trait: t })) }; +} + +function builtin(id: string, def: Partial): TraitDefinition { + return { id, name: id, builtin: true, flags: {}, ...def }; +} + +function plugin(id: string, def: Partial): TraitDefinition { + return { id, name: id, flags: {}, ...def }; +} + +/** A registry seeded with a representative built-in set used across tests. */ +function seeded(): TraitRegistry { + const r = new TraitRegistry(); + r.register(builtin("intake", { flags: { intake: true } })); + r.register(builtin("complete", { flags: { complete: true } })); + r.register(builtin("archived", { flags: { archived: true, hiddenFromBoard: true } })); + r.register(builtin("wip", { flags: { countsTowardWip: true } })); + r.register(builtin("wip2", { flags: { countsTowardWip: true } })); + r.register(builtin("merge-blocker", { flags: { mergeBlocker: true }, hooks: { guard: true } })); + r.register(builtin("timing", { flags: { timing: true }, hooks: { onEnter: true, onExit: true } })); + return r; +} + +describe("TraitRegistry — registration", () => { + it("rejects a duplicate trait id", () => { + const r = new TraitRegistry(); + r.register(builtin("intake", { flags: { intake: true } })); + expect(() => r.register(builtin("intake", { flags: { intake: true } }))).toThrowError( + TraitRegistrationError, + ); + try { + r.register(builtin("intake", { flags: { intake: true } })); + } catch (err) { + expect((err as TraitRegistrationError).reason).toBe("duplicate-id"); + } + }); + + it("blocks a non-builtin from overriding a built-in namespace id", () => { + const r = new TraitRegistry(); + r.register(builtin("complete", { flags: { complete: true } })); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("complete", { flags: {} })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught).toBeInstanceOf(TraitRegistrationError); + expect(caught?.reason).toBe("builtin-namespace-protected"); + }); + + it("rejects a non-builtin declaring the restricted `complete` flag", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:done", { flags: { complete: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-flag"); + }); + + it("rejects a non-builtin declaring the restricted `archived` flag", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:arch", { flags: { archived: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-flag"); + }); + + it("rejects a non-builtin declaring a sync `guard` hook (built-in only)", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:guard", { flags: {}, hooks: { guard: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-guard-hook"); + }); + + it("allows a non-builtin declaring async-only hooks", () => { + const r = new TraitRegistry(); + expect(() => + r.register( + plugin("my-plugin:gate", { + flags: { gate: true }, + hooks: { gate: true, onEnter: true, onExit: true, releaseCondition: true }, + }), + ), + ).not.toThrow(); + }); +}); + +describe("TraitRegistry — flag resolution", () => { + it("merges effective flags across a column's traits (OR)", () => { + const r = seeded(); + const flags = r.resolveColumnFlags(col("in-progress", ["wip", "timing"])); + expect(flags.countsTowardWip).toBe(true); + expect(flags.timing).toBe(true); + expect(flags.complete).toBeUndefined(); + }); + + it("ignores unknown trait ids in flag resolution", () => { + const r = seeded(); + const flags = r.resolveColumnFlags(col("x", ["wip", "nope"])); + expect(flags.countsTowardWip).toBe(true); + }); +}); + +describe("TraitRegistry — composition validator", () => { + it("rejects complete + countsTowardWip with its reason code", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["complete", "wip"])]); + expect(v.find((x) => x.code === "complete-with-wip")?.severity).toBe("error"); + }); + + it("rejects two capacity (wip) traits on one column", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["wip", "wip2"])]); + const hit = v.find((x) => x.code === "two-capacity-traits"); + expect(hit?.severity).toBe("error"); + expect(hit?.traitIds.sort()).toEqual(["wip", "wip2"]); + }); + + it("rejects complete + intake", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["complete", "intake"])]); + expect(v.find((x) => x.code === "complete-with-intake")?.severity).toBe("error"); + }); + + it("rejects archived + countsTowardWip", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["archived", "wip"])]); + expect(v.find((x) => x.code === "archived-with-wip")?.severity).toBe("error"); + }); + + it("rejects more than one intake column per workflow", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("a", ["intake"]), col("b", ["intake"])]); + expect(v.find((x) => x.code === "multiple-intake-columns")?.severity).toBe("error"); + }); + + it("a valid single-intake / clean column set passes", () => { + const r = seeded(); + const v = r.validateColumnTraits([ + col("triage", ["intake"]), + col("in-progress", ["wip", "timing"]), + col("done", ["complete"]), + ]); + expect(v).toEqual([]); + }); + + it("conflicting boolean flags reject at save (validator), not at runtime", () => { + const r = seeded(); + // The conflict is surfaced by the validator (save-time), not on flag merge. + const flags = r.resolveColumnFlags(col("c", ["complete", "wip"])); + expect(flags.complete && flags.countsTowardWip).toBe(true); // merge does not throw + const v = r.validateColumnTraits([col("c", ["complete", "wip"])]); + expect(v.some((x) => x.code === "complete-with-wip" && x.severity === "error")).toBe(true); + }); + + it("unknown trait is a save-blocking error in save mode", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "save"); + const hit = v.find((x) => x.code === "unknown-trait"); + expect(hit?.severity).toBe("error"); + }); + + it("load-time re-validation degrades unknown trait to advisory, not error", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "load"); + const hit = v.find((x) => x.code === "unknown-trait"); + expect(hit?.severity).toBe("degraded"); + // The definition still "loads" — there is no error-severity violation. + expect(v.some((x) => x.severity === "error")).toBe(false); + }); +}); + +describe("TraitRegistry — hook implementation DI", () => { + it("resolves a declared hook with no registered impl to a no-op + audit warning", () => { + const r = seeded(); + const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard"); + expect(typeof impl).toBe("function"); + expect(impl?.()).toBeUndefined(); // no-op + expect(warning?.kind).toBe("missing-hook-impl"); + expect(warning?.traitId).toBe("merge-blocker"); + expect(warning?.hookKind).toBe("guard"); + }); + + it("resolves a registered impl without a warning", () => { + const r = seeded(); + let called = false; + r.registerTraitHookImpl("merge-blocker", "guard", () => { + called = true; + return "ok"; + }); + const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard"); + expect(warning).toBeUndefined(); + expect(impl?.()).toBe("ok"); + expect(called).toBe(true); + }); + + it("returns no impl and no warning when the trait does not declare the hook", () => { + const r = seeded(); + const { impl, warning } = r.resolveTraitHook("wip", "onEnter"); + expect(impl).toBeUndefined(); + expect(warning).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/transition-parity.test.ts b/packages/core/src/__tests__/transition-parity.test.ts new file mode 100644 index 0000000000..17bc033994 --- /dev/null +++ b/packages/core/src/__tests__/transition-parity.test.ts @@ -0,0 +1,304 @@ +// @vitest-environment node +// +// TRANSITION-PARITY SUITE (U4). +// +// Proves the flag-ON workflow-resolved transition path reproduces the legacy +// VALID_TRANSITIONS contract for the default workflow, and exercises the U4 +// plan scenarios: +// - VALID_TRANSITIONS parity (allowed AND rejected sets identical) +// - FN-5147 terminal-until-merged (both paths) +// - hard-cancel user vs engine (userPaused + abort-on-exit bypass) +// - handoff bypass + exactly-once enqueue across a simulated crash +// - crash-mid-transition marker recovery (SQLite authoritative) +// - unknown-column rejection +// - guard rejection typed (flag-ON) vs legacy string (flag-OFF) +// - in-txn capacity enforcement (U6; NEVER bypassable — KTD-10) + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { VALID_TRANSITIONS } from "../types.js"; +import type { Column, Task } from "../types.js"; +import { TransitionRejectionError } from "../store.js"; +import { resolveAllowedColumns, workflowHasColumn } from "../workflow-transitions.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { readTransitionPending } from "../transition-pending.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + +describe("transition-parity — default workflow column adjacency == VALID_TRANSITIONS", () => { + it("reproduces VALID_TRANSITIONS exactly for every column (allowed + rejected)", () => { + for (const from of ALL_COLUMNS) { + const legacy = new Set(VALID_TRANSITIONS[from]); + const resolved = new Set(resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, from)); + // Allowed sets identical. + expect([...resolved].sort()).toEqual([...legacy].sort()); + // Rejected sets identical (complement over all columns). + for (const to of ALL_COLUMNS) { + if (from === to) continue; + expect(resolved.has(to)).toBe(legacy.has(to)); + } + } + }); + + it("recognizes exactly the six default columns", () => { + for (const c of ALL_COLUMNS) { + expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, c)).toBe(true); + } + expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, "made-up")).toBe(false); + }); +}); + +describe("transition-parity — store flag-ON scenarios", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function seedInColumn(column: Column): Promise { + const task = await store.createTask({ description: `seed-${column}` }); + const u = { moveSource: "user" as const }; + if (column === "triage") return task; + await store.moveTask(task.id, "todo", u); + if (column === "todo") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "in-progress", u); + if (column === "in-progress") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "in-review", { ...u, allowDirectInReviewMove: true }); + if (column === "in-review") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + if (column === "done") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "archived", u); + return store.getTask(task.id) as Promise; + } + + it("FN-5147: user move in-review → done blocked by merge-blocker with typed rejection", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { steps: [{ name: "x", status: "pending" }] as Task["steps"] }); + let caught: unknown; + try { + await store.moveTask(task.id, "done", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("merge-blocked"); + expect((caught as TransitionRejectionError).rejection.retryable).toBe(true); + }); + + it("FN-5147: engine-sourced move bypasses the merge-blocker guard", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { steps: [{ name: "x", status: "pending" }] as Task["steps"] }); + const moved = await store.moveTask(task.id, "done", { moveSource: "engine" }); + expect(moved.column).toBe("done"); + }); + + it("hard-cancel: user in-progress → todo sets userPaused; engine does not", async () => { + const userTask = await seedInColumn("in-progress"); + const u = await store.moveTask(userTask.id, "todo", { moveSource: "user" }); + expect(u.userPaused).toBe(true); + + const engineTask = await seedInColumn("in-progress"); + const e = await store.moveTask(engineTask.id, "todo", { moveSource: "engine" }); + expect(e.userPaused).toBeUndefined(); + }); + + it("unknown column rejects with typed unknown-column code, card untouched", async () => { + const task = await seedInColumn("todo"); + let caught: unknown; + try { + await store.moveTask(task.id, "made-up" as Column, { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("unknown-column"); + const after = await store.getTask(task.id); + expect(after?.column).toBe("todo"); + }); + + it("guard/adjacency rejection is typed (not a bare Error string)", async () => { + const task = await seedInColumn("archived"); + // archived → todo is not a legal default-workflow transition. + let caught: unknown; + try { + await store.moveTask(task.id, "todo", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("guard-rejected"); + }); + + it("handoffToReview maps skipMergeBlocker onto bypassGuards and enqueues exactly once", async () => { + const task = await seedInColumn("in-progress"); + await store.handoffToReview(task.id, { + ownerAgentId: "agent-1", + evidence: { runId: "run-1", agentId: "agent-1", reason: "complete" }, + } as Parameters[1]); + const after = await store.getTask(task.id); + expect(after?.column).toBe("in-review"); + // Idempotent re-handoff (same-column path) must not double-enqueue. + await store.handoffToReview(task.id, { + ownerAgentId: "agent-1", + evidence: { runId: "run-2", agentId: "agent-1", reason: "complete" }, + } as Parameters[1]); + const queueCount = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db + .prepare("SELECT COUNT(*) AS n FROM mergeQueue WHERE taskId = ?") + .get(task.id) as { n: number }; + expect(queueCount.n).toBe(1); + }); + + it("transitionPending marker is written in-txn and cleared post-commit (happy path)", async () => { + const task = await seedInColumn("todo"); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + const db = (store as unknown as { db: Parameters[0] }).db; + // Happy path: marker cleared after the post-commit hook runner. + expect(readTransitionPending(db, task.id)).toBeNull(); + }); + + it("crash-mid-transition: a persisted marker is recoverable from SQLite with hooksRemaining intact", async () => { + const task = await seedInColumn("todo"); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + // Simulate a crash AFTER commit but BEFORE the marker clear by re-writing a + // marker directly (the in-txn write path is the same helper). Recovery reads + // it back from SQLite (authoritative), not from task.json. + const db = (store as unknown as { db: Parameters[0] }).db; + (db as unknown as { prepare: (s: string) => { run: (...a: unknown[]) => unknown } }) + .prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?") + .run( + JSON.stringify({ toColumn: "in-progress", hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }), + task.id, + ); + const pending = readTransitionPending(db, task.id); + expect(pending).not.toBeNull(); + expect(pending?.toColumn).toBe("in-progress"); + expect(pending?.hooksRemaining).toContain("default-workflow:postCommit"); + }); + + it("worktree ordering: allocateWorktree runs (and is applied) for a flag-ON move into in-progress", async () => { + const task = await seedInColumn("todo"); + let allocatorCalled = false; + const moved = await store.moveTask(task.id, "in-progress", { + moveSource: "user", + allocateWorktree: () => { + allocatorCalled = true; + return "/tmp/wt/seed-todo"; + }, + }); + expect(allocatorCalled).toBe(true); + expect(moved.worktree).toBe("/tmp/wt/seed-todo"); + // Worktree allocation is NOT a hook — it is a substrate capability invoked + // synchronously before the move commits; the committed row carries it. + const after = await store.getTask(task.id); + expect(after?.worktree).toBe("/tmp/wt/seed-todo"); + }); + + it("U6 in-txn capacity: default-workflow in-progress WIP reads through maxConcurrent and rejects the over-limit move", async () => { + // The default workflow's in-progress column has a `wip` trait whose limit + // reads through to settings.maxConcurrent (legacy parity). With limit 1, the + // first move into in-progress commits and a second rejects with the typed + // capacity-exhausted code. + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const t1 = await seedInColumn("todo"); + const t2 = await seedInColumn("todo"); + const m1 = await store.moveTask(t1.id, "in-progress", { moveSource: "user" }); + expect(m1.column).toBe("in-progress"); + + let caught: unknown; + try { + await store.moveTask(t2.id, "in-progress", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted"); + // The rejected card is untouched. + expect((await store.getTask(t2.id))?.column).toBe("todo"); + }); + + it("U6 capacity is NEVER bypassable (KTD-10): an engine/bypassGuards move into a full column still rejects", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const t1 = await seedInColumn("todo"); + const t2 = await seedInColumn("todo"); + await store.moveTask(t1.id, "in-progress", { moveSource: "user" }); + + let caught: unknown; + try { + // Engine-sourced + bypassGuards skips trait guards, but capacity is not a + // guard — it must still reject. + await store.moveTask(t2.id, "in-progress", { moveSource: "engine", bypassGuards: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted"); + }); + + it("U6 capacity counts cards mid-transitionPending (they hold their slot from commit time)", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const t1 = await seedInColumn("todo"); + const t2 = await seedInColumn("todo"); + await store.moveTask(t1.id, "in-progress", { moveSource: "user" }); + // Simulate a crash before t1's marker clears: it is still mid-transition into + // in-progress, holding its slot. (Its column already equals in-progress, so + // this also independently holds the slot; this asserts the marker path does + // not under-count or double-count.) + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?").run( + JSON.stringify({ toColumn: "in-progress", hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }), + t1.id, + ); + let caught: unknown; + try { + await store.moveTask(t2.id, "in-progress", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted"); + }); +}); + +describe("transition-parity — flag-OFF keeps legacy thrown strings (no behavior change)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("rejects an illegal move with a bare Error containing the legacy message (not TransitionRejectionError)", async () => { + const task = await store.createTask({ description: "legacy reject" }); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + await store.moveTask(task.id, "archived", { moveSource: "user" }); + let caught: unknown; + try { + await store.moveTask(task.id, "todo", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(TransitionRejectionError); + expect((caught as Error).message).toMatch(/Invalid transition/); + }); + + it("flag-OFF does NOT write a transitionPending marker", async () => { + const task = await store.createTask({ description: "no marker" }); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + const db = (store as unknown as { db: Parameters[0] }).db; + expect(readTransitionPending(db, task.id)).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/transition-pending-recovery.test.ts b/packages/core/src/__tests__/transition-pending-recovery.test.ts new file mode 100644 index 0000000000..064393f800 --- /dev/null +++ b/packages/core/src/__tests__/transition-pending-recovery.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node +// +// #1401 + #1409: store-level recovery / evacuation passes for the workflow +// columns feature. +// +// #1401 — transitionPending recovery sweep: +// * a crash-simulated stale marker is recovered (cleared) by the sweep, +// * the phantom capacity slot the marker reserved is released so a fresh +// card can re-enter a full (capacity=1) column afterwards, +// * the sweep is idempotent (a second run finds nothing). +// +// #1409 — flag ON→OFF evacuation: +// * toggling workflowColumns OFF with a card in a custom column re-homes it +// to a legacy column, the board stays listable, and legacy moves work. +// * a flag-OFF store init evacuates a card left in a custom column. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { makeTransitionPending, serializeTransitionPending } from "../transition-types.js"; + +/** A custom workflow whose middle column carries a WIP capacity limit of 1. */ +function cappedIr(): WorkflowIr { + return { + version: "v2", + name: "capped", + columns: [ + { id: "intake", name: "intake", traits: [{ trait: "intake" }] }, + { + id: "build", + name: "build", + traits: [{ trait: "wip", config: { limit: 1, countPending: true } }], + }, + { id: "ship", name: "ship", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake" }, + { id: "work", kind: "prompt", column: "build", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "ship" }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +function simpleCustomIr(): WorkflowIr { + return { + version: "v2", + name: "simple-custom", + columns: [ + { id: "intake", name: "intake", traits: [{ trait: "intake" }] }, + { id: "build", name: "build", traits: [] }, + { id: "ship", name: "ship", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake" }, + { id: "work", kind: "prompt", column: "build", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "ship" }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +describe("#1401 transitionPending recovery sweep", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + function rawDb(): { + prepare: (s: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown }; + } { + return (store as unknown as { db: ReturnType }).db; + } + + function readMarkerColumn(taskId: string): string | null { + const row = rawDb() + .prepare(`SELECT transitionPending FROM tasks WHERE id = ?`) + .get(taskId) as { transitionPending: string | null } | undefined; + return row?.transitionPending ?? null; + } + + it("recovers a crash-simulated stale marker and is idempotent", async () => { + const t = await store.createTask({ description: "stale-marker" }); + // Simulate a crash that left a transitionPending marker set forever. + const marker = serializeTransitionPending( + makeTransitionPending("build", ["default-workflow:postCommit"], Date.now() - 60_000), + ); + rawDb().prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(marker, t.id); + expect(readMarkerColumn(t.id)).not.toBeNull(); + + const first = await store.recoverStaleTransitionPending(); + expect(first.scanned).toBeGreaterThanOrEqual(1); + expect(first.recovered).toBe(1); + // Marker cleared → capacity slot released. + expect(readMarkerColumn(t.id)).toBeNull(); + + // Idempotent: nothing left to recover. + const second = await store.recoverStaleTransitionPending(); + expect(second.recovered).toBe(0); + }); + + it("releases the phantom capacity slot a stale marker reserved (count returns to normal)", async () => { + const wf = await store.createWorkflowDefinition({ name: "capped", ir: cappedIr() }); + + // A "ghost" task crashed mid-transition into the capacity-1 "build" column: + // its marker reserves the only slot even though it never committed there. + const ghost = await store.createTask({ description: "ghost" }); + await store.selectTaskWorkflowAndReconcile(ghost.id, wf.id); + const ghostMarker = serializeTransitionPending( + makeTransitionPending("build", ["default-workflow:postCommit"], Date.now() - 60_000), + ); + rawDb().prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(ghostMarker, ghost.id); + + // A fresh card in the same workflow cannot enter "build": the phantom marker + // is counted as occupying the single capacity slot. + const fresh = await store.createTask({ description: "fresh" }); + await store.selectTaskWorkflowAndReconcile(fresh.id, wf.id); + expect((await store.getTask(fresh.id)).column).toBe("intake"); + + let blocked: unknown; + try { + await store.moveTask(fresh.id, "build", { moveSource: "user" }); + } catch (e) { + blocked = e; + } + expect(blocked).toBeInstanceOf(Error); + expect((await store.getTask(fresh.id)).column).toBe("intake"); + + // Recovery clears the stale marker, releasing the slot. + const result = await store.recoverStaleTransitionPending(); + expect(result.recovered).toBeGreaterThanOrEqual(1); + + // Now the fresh card can enter the capacity column. + await store.moveTask(fresh.id, "build", { moveSource: "user" }); + expect((await store.getTask(fresh.id)).column).toBe("build"); + }); +}); + +describe("#1409 flag ON→OFF evacuation", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("toggling OFF re-homes a card from a custom column to a legacy column; moves work", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const wf = await store.createWorkflowDefinition({ name: "simple-custom", ir: simpleCustomIr() }); + const task = await store.createTask({ description: "evac" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + expect((await store.getTask(task.id)).column).toBe("intake"); + + // Toggle OFF — evacuation re-homes the card to the nearest legacy column + // (the default workflow's entry column, triage). + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + expect((await store.getTask(task.id)).column).toBe("triage"); + + // Board listable; legacy moves work from the evacuated column. + await expect(store.listTasks()).resolves.toBeDefined(); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + expect((await store.getTask(task.id)).column).toBe("in-progress"); + }); + + it("evacuateCustomColumnsToLegacy is idempotent (a second run is a no-op)", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const wf = await store.createWorkflowDefinition({ name: "simple-custom-2", ir: simpleCustomIr() }); + const task = await store.createTask({ description: "evac2" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + + // First explicit run already evacuated (via the toggle); a fresh run is a no-op. + const again = await store.evacuateCustomColumnsToLegacy("flag-off-init"); + expect(again.evacuated).toBe(0); + expect((await store.getTask(task.id)).column).toBe("triage"); + }); +}); diff --git a/packages/core/src/__tests__/transition-types.test.ts b/packages/core/src/__tests__/transition-types.test.ts new file mode 100644 index 0000000000..6eaad0cc76 --- /dev/null +++ b/packages/core/src/__tests__/transition-types.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Database, SCHEMA_VERSION } from "../db.js"; +import { + TRANSITION_REJECTION_CODES, + type TransitionRejectionCode, + deserializeTransitionPending, + deserializeTransitionRejection, + makeTransitionPending, + makeTransitionRejection, + serializeTransitionPending, + serializeTransitionRejection, + transitionOk, + transitionRejected, +} from "../transition-types.js"; +import { + clearTransitionPending, + reconcileHooksRemaining, + readTransitionPending, + writeTransitionPending, +} from "../transition-pending.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-transition-types-")); +} + +describe("TransitionRejection (de)serialization across the API boundary", () => { + it("round-trips every rejection code", () => { + for (const code of TRANSITION_REJECTION_CODES) { + const rejection = makeTransitionRejection(code, `transition.reject.${code}`, code === "capacity-exhausted"); + const wire = serializeTransitionRejection(rejection); + // Wire form is plain JSON — no class instances survive the boundary. + expect(typeof wire).toBe("string"); + const parsedRaw = JSON.parse(wire) as Record; + expect(parsedRaw.code).toBe(code); + const back = deserializeTransitionRejection(wire); + expect(back).toEqual(rejection); + } + }); + + it("round-trips the optional detail field and omits it when absent", () => { + const withDetail = makeTransitionRejection("guard-rejected", "k", false, "guard X said no"); + expect(deserializeTransitionRejection(serializeTransitionRejection(withDetail))).toEqual(withDetail); + + const withoutDetail = makeTransitionRejection("unknown-column", "k", false); + expect("detail" in withoutDetail).toBe(false); + const wire = serializeTransitionRejection(withoutDetail); + expect(JSON.parse(wire)).not.toHaveProperty("detail"); + expect(deserializeTransitionRejection(wire)).toEqual(withoutDetail); + }); + + it("rejects malformed / structurally invalid payloads with null (never throws)", () => { + expect(deserializeTransitionRejection("not json{{")).toBeNull(); + expect(deserializeTransitionRejection("null")).toBeNull(); + expect(deserializeTransitionRejection("42")).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "not-a-code", messageKey: "k", retryable: true }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", retryable: true }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: "yes" }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: true, detail: 7 }))).toBeNull(); + }); + + it("builds discriminated TransitionResult values", () => { + const ok = transitionOk("in-review"); + expect(ok).toEqual({ ok: true, toColumn: "in-review" }); + + const rejection = makeTransitionRejection("merge-blocked", "transition.merge-blocked", true); + const rejected = transitionRejected(rejection); + expect(rejected).toEqual({ ok: false, rejection }); + }); + + it("exposes the full, exhaustive code set", () => { + const expected: TransitionRejectionCode[] = [ + "guard-rejected", + "capacity-exhausted", + "unknown-column", + "workflow-mismatch", + "merge-blocked", + ]; + expect([...TRANSITION_REJECTION_CODES].sort()).toEqual([...expected].sort()); + }); +}); + +describe("TransitionPending (de)serialization", () => { + it("round-trips a marker including hooksRemaining order and startedAt", () => { + const marker = makeTransitionPending("in-progress", ["timing:onEnter", "abort-on-exit:onExit"], 1_700_000_000_000); + const wire = serializeTransitionPending(marker); + expect(deserializeTransitionPending(wire)).toEqual(marker); + }); + + it("copies hooksRemaining so the marker does not alias caller state", () => { + const hooks = ["a", "b"]; + const marker = makeTransitionPending("todo", hooks); + hooks.push("c"); + expect(marker.hooksRemaining).toEqual(["a", "b"]); + }); + + it("drops non-string hook entries defensively and rejects malformed markers", () => { + expect(deserializeTransitionPending("garbage")).toBeNull(); + expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", startedAt: 1 }))).toBeNull(); + expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", hooksRemaining: [], startedAt: "soon" }))).toBeNull(); + const recovered = deserializeTransitionPending( + JSON.stringify({ toColumn: "x", hooksRemaining: ["keep", 5, null, "also"], startedAt: 10 }), + ); + expect(recovered).toEqual({ toColumn: "x", hooksRemaining: ["keep", "also"], startedAt: 10 }); + }); +}); + +describe("reconcileHooksRemaining (missing-plugin-hook, U3-level)", () => { + it("keeps known hooks and drops unknown ones with one audit warning each", () => { + const known = new Set(["builtin:timing", "builtin:abort"]); + const result = reconcileHooksRemaining(["builtin:timing", "plugin:gone", "builtin:abort", "plugin:also-gone"], known); + expect(result.hooksRemaining).toEqual(["builtin:timing", "builtin:abort"]); + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0]).toContain("plugin:gone"); + expect(result.warnings[1]).toContain("plugin:also-gone"); + }); + + it("returns no warnings when every hook is known", () => { + const result = reconcileHooksRemaining(["a"], new Set(["a", "b"])); + expect(result).toEqual({ hooksRemaining: ["a"], warnings: [] }); + }); +}); + +describe("transitionPending marker lifecycle (helper-level, U3)", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + db.exec( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-1', 'task', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + }); + + afterEach(async () => { + try { + db.close(); + } catch { + // already closed + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("set with a move, then cleared after hooks complete", () => { + expect(readTransitionPending(db, "FN-1")).toBeNull(); + + // Simulate the in-txn write that accompanies a column change (U4 wires this): + // the column change and the marker write land in one transaction. + db.exec("BEGIN"); + db.prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("in-progress", "FN-1"); + writeTransitionPending(db, "FN-1", makeTransitionPending("in-progress", ["timing:onEnter"], 1234)); + db.exec("COMMIT"); + + const after = readTransitionPending(db, "FN-1"); + expect(after).toEqual({ toColumn: "in-progress", hooksRemaining: ["timing:onEnter"], startedAt: 1234 }); + const movedRow = db.prepare(`SELECT "column" AS col FROM tasks WHERE id = ?`).get("FN-1") as { col: string }; + expect(movedRow.col).toBe("in-progress"); + + // Post-commit hooks ran -> clear. + clearTransitionPending(db, "FN-1"); + expect(readTransitionPending(db, "FN-1")).toBeNull(); + }); + + it("survives a simulated crash: marker recoverable with hooksRemaining intact", () => { + writeTransitionPending(db, "FN-1", makeTransitionPending("in-review", ["merge:onEnter", "stall:onEnter"], 999)); + db.close(); + + // Re-open as a fresh handle (the post-commit hook runner never ran -> crash). + const reopened = new Database(fusionDir); + reopened.init(); + const recovered = readTransitionPending(reopened, "FN-1"); + expect(recovered).toEqual({ toColumn: "in-review", hooksRemaining: ["merge:onEnter", "stall:onEnter"], startedAt: 999 }); + reopened.close(); + db = new Database(fusionDir); + db.init(); + }); + + it("reads back exclusively from the SQLite row (authoritative store, ADR-0001)", () => { + // The helper only ever consults the SQLite tasks row; there is no task.json + // read path. Writing the marker and reading it through a brand-new handle + // proves SQLite is the single source of truth. + writeTransitionPending(db, "FN-1", makeTransitionPending("done", ["complete:onEnter"], 5)); + db.close(); + const fresh = new Database(fusionDir); + fresh.init(); + expect(readTransitionPending(fresh, "FN-1")).toEqual({ + toColumn: "done", + hooksRemaining: ["complete:onEnter"], + startedAt: 5, + }); + fresh.close(); + db = new Database(fusionDir); + db.init(); + }); + + it("returns undefined for a missing task and null for a corrupt marker", () => { + expect(readTransitionPending(db, "FN-nonexistent")).toBeUndefined(); + db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run("not json{{", "FN-1"); + expect(readTransitionPending(db, "FN-1")).toBeNull(); + }); +}); + +describe("tasks.transitionPending migration (106)", () => { + let tmpDir: string; + let fusionDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("adds the column when migrating a pre-106 tasks table, leaving existing rows NULL", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '105')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + + db.init(); + + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toContain("transitionPending"); + + const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = 'FN-legacy'").get() as { + transitionPending: string | null; + }; + expect(row.transitionPending).toBeNull(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + db.close(); + }); + + it("is idempotent: running init twice does not error and stays at the current version", () => { + const db = new Database(fusionDir); + db.init(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + // Second init on the same DB is a no-op (version already current). + expect(() => db.init()).not.toThrow(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.filter((c) => c.name === "transitionPending")).toHaveLength(1); + db.close(); + + // Re-open + init a third time on the persisted DB. + const reopened = new Database(fusionDir); + expect(() => reopened.init()).not.toThrow(); + expect(reopened.getSchemaVersion()).toBe(SCHEMA_VERSION); + reopened.close(); + }); + + it("is a no-op on a fresh DB: column present from the base CREATE TABLE", () => { + const db = new Database(fusionDir); + db.init(); + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toContain("transitionPending"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + db.close(); + }); +}); diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts index 27f17e95fa..7d78accce5 100644 --- a/packages/core/src/__tests__/workflow-definition-store.test.ts +++ b/packages/core/src/__tests__/workflow-definition-store.test.ts @@ -66,6 +66,60 @@ describe("TaskStore workflow definitions (U1)", () => { ).rejects.toThrow(/name is required/i); }); + describe("rollback compat — v1/v2 persistence (#1405)", () => { + function rawIr(id: string): { version: string } { + const row = (store as any).db + .prepare("SELECT ir FROM workflows WHERE id = ?") + .get(id) as { ir: string }; + return JSON.parse(row.ir); + } + + // A pure-v1 graph: only v1 node kinds, default columns at default placement. + const pureV1 = (): WorkflowIr => makeIr(); + + // A v2 graph using a custom column (a genuine v2 feature). + const v2Custom = (): WorkflowIr => + ({ + version: "v2", + name: "v2-feature", + columns: [ + { id: "triage", name: "triage", traits: [] }, + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "in-review", name: "in-review", traits: [] }, + { id: "done", name: "done", traits: [] }, + { id: "archived", name: "archived", traits: [] }, + { id: "review-queue", name: "Review Queue", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + }) as unknown as WorkflowIr; + + it("flag OFF: a pure-v1 workflow persists in the v1 shape on create and update", async () => { + const created = await store.createWorkflowDefinition({ name: "Pure", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v1"); + await store.updateWorkflowDefinition(created.id, { description: "edit", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v1"); + // Read-path still resolves it as the upgraded v2 in-memory shape. + const reloaded = await store.getWorkflowDefinition(created.id); + expect(reloaded?.ir.version).toBe("v2"); + }); + + it("flag OFF: a v2-feature workflow persists as v2 regardless", async () => { + const created = await store.createWorkflowDefinition({ name: "Feat", ir: v2Custom() }); + expect(rawIr(created.id).version).toBe("v2"); + }); + + it("flag ON: a pure-v1 workflow persists as v2", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const created = await store.createWorkflowDefinition({ name: "OnFlag", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v2"); + }); + }); + it("updates name, description, IR, and layout and advances updatedAt", async () => { const created = await store.createWorkflowDefinition({ name: "V1", ir: makeIr() }); await new Promise((r) => setTimeout(r, 2)); diff --git a/packages/core/src/__tests__/workflow-ir-foreach.test.ts b/packages/core/src/__tests__/workflow-ir-foreach.test.ts new file mode 100644 index 0000000000..2bb114a9f6 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-foreach.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, it } from "vitest"; +import { + parseWorkflowIr, + serializeWorkflowIr, + downgradeIrToV1IfPure, + WorkflowIrError, +} from "../workflow-ir.js"; +import type { + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrV2, +} from "../workflow-ir-types.js"; + +// Step-inversion (U1) — foreach / step-review / parse-steps / code / rework / +// fields validation. + +const defaultColumns: WorkflowIrV2["columns"] = [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, +]; + +function v2( + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[], + extra: Partial = {}, +): WorkflowIrV2 { + return { version: "v2", name: "test", columns: defaultColumns, nodes, edges, ...extra }; +} + +/** A minimal valid foreach template: step-execute → step-review(approve→exit). */ +function stepTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } { + return { + nodes: [ + { id: "se", kind: "prompt", config: { seam: "step-execute" } }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [ + { from: "se", to: "rev" }, + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ], + }; +} + +/** A graph: start → parse-steps → foreach → end. */ +function graphWithForeach( + foreachConfig: Record, + extra: Partial = {}, +): WorkflowIrV2 { + return v2( + [ + { id: "start", kind: "start" }, + { id: "ps", kind: "parse-steps", config: { artifact: "PROMPT.md", parser: "step-headings" } }, + { id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate(), ...foreachConfig } }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "ps" }, + { from: "ps", to: "fe" }, + { from: "fe", to: "end" }, + ], + extra, + ); +} + +describe("foreach validation", () => { + it("parses a valid foreach dominated by parse-steps", () => { + const ir = parseWorkflowIr(graphWithForeach({})) as WorkflowIrV2; + expect(ir.version).toBe("v2"); + const fe = ir.nodes.find((n) => n.id === "fe")!; + expect(fe.kind).toBe("foreach"); + }); + + it("rejects foreach with empty template", () => { + const ir = graphWithForeach({ template: { nodes: [], edges: [] } }); + expect(() => parseWorkflowIr(ir)).toThrow(/non-empty/); + }); + + it("rejects template with two entry nodes", () => { + const tmpl = { + nodes: [ + { id: "a", kind: "prompt", config: { seam: "step-execute" } }, + { id: "b", kind: "prompt" }, + ] as WorkflowIrNode[], + edges: [] as WorkflowIrEdge[], + }; + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow( + /exactly one entry/, + ); + }); + + it("rejects template with two exit nodes", () => { + const tmpl = { + nodes: [ + { id: "a", kind: "prompt", config: { seam: "step-execute" } }, + { id: "b", kind: "prompt" }, + { id: "c", kind: "prompt" }, + ] as WorkflowIrNode[], + edges: [ + { from: "a", to: "b" }, + { from: "a", to: "c" }, + ] as WorkflowIrEdge[], + }; + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow( + /exactly one (entry|exit)/, + ); + }); + + it("rejects nested foreach in a template", () => { + const tmpl = { + nodes: [ + { id: "inner", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } }, + ] as WorkflowIrNode[], + edges: [] as WorkflowIrEdge[], + }; + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow( + /nested foreach/, + ); + }); + + it("rejects step-execute at the top level", () => { + const ir = v2( + [ + { id: "start", kind: "start" }, + { id: "se", kind: "prompt", config: { seam: "step-execute" } }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "se" }, + { from: "se", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/only legal inside a foreach template/); + }); + + it("rejects step-execute inside a split branch (extends SEAM_FORBIDDEN_IN_BRANCH)", () => { + const tmpl = { + nodes: [ + { id: "split", kind: "split" }, + { id: "se", kind: "prompt", config: { seam: "step-execute" } }, + { id: "other", kind: "prompt" }, + { id: "join", kind: "join" }, + ] as WorkflowIrNode[], + edges: [ + { from: "split", to: "se" }, + { from: "split", to: "other" }, + { from: "se", to: "join" }, + { from: "other", to: "join" }, + ] as WorkflowIrEdge[], + }; + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow( + /step-execute.*forbidden inside a parallel branch/, + ); + }); + + it("rejects a rework edge crossing the template boundary", () => { + const tmpl = stepTemplate(); + // Point the rework edge at a node outside the template. + tmpl.edges = tmpl.edges.map((e) => + e.kind === "rework" ? { ...e, to: "end" } : e, + ); + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow( + /both endpoints inside the same template/, + ); + }); + + it("rejects a top-level rework edge", () => { + const ir = v2( + [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt" }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "a" }, + { from: "a", to: "end" }, + { from: "end", to: "a", kind: "rework" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/only legal inside a foreach template/); + }); + + it("rejects foreach not dominated by a parse-steps node", () => { + const ir = v2( + [ + { id: "start", kind: "start" }, + { id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "fe" }, + { from: "fe", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/must be dominated by a parse-steps node/); + }); + + it("rejects foreach when parse-steps is only on one branch (not all paths)", () => { + // start → split into (ps→join) and (direct→join), join → fe. + const ir = v2( + [ + { id: "start", kind: "start" }, + { id: "split", kind: "split" }, + { id: "ps", kind: "parse-steps", config: { artifact: "PROMPT.md", parser: "step-headings" } }, + { id: "direct", kind: "prompt" }, + { id: "join", kind: "join" }, + { id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "ps" }, + { from: "split", to: "direct" }, + { from: "ps", to: "join" }, + { from: "direct", to: "join" }, + { from: "join", to: "fe" }, + { from: "fe", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/must be dominated by a parse-steps node/); + }); +}); + +describe("foreach mode / isolation / concurrency", () => { + it("rejects parallel + shared", () => { + expect(() => + parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "shared" })), + ).toThrow(/cannot combine mode 'parallel' with isolation 'shared'/); + }); + + it("accepts parallel + worktree", () => { + expect(() => + parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 4 })), + ).not.toThrow(); + }); + + it("rejects concurrency on sequential mode", () => { + expect(() => + parseWorkflowIr(graphWithForeach({ mode: "sequential", concurrency: 2 })), + ).toThrow(/concurrency is only valid in 'parallel' mode/); + }); + + it("rejects concurrency out of range", () => { + expect(() => + parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 9 })), + ).toThrow(/concurrency must be an integer in 1\.\.8/); + expect(() => + parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 0 })), + ).toThrow(/concurrency must be an integer in 1\.\.8/); + }); +}); + +describe("foreach maxReworkCycles clamp", () => { + it("rejects maxReworkCycles < 1", () => { + expect(() => parseWorkflowIr(graphWithForeach({ maxReworkCycles: 0 }))).toThrow( + /maxReworkCycles must be an integer >= 1/, + ); + }); + + it("clamps maxReworkCycles > 10 to 10", () => { + const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 99 })) as WorkflowIrV2; + const fe = ir.nodes.find((n) => n.id === "fe")!; + expect((fe.config as { maxReworkCycles: number }).maxReworkCycles).toBe(10); + }); + + it("keeps maxReworkCycles <= 10 unchanged", () => { + const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 5 })) as WorkflowIrV2; + const fe = ir.nodes.find((n) => n.id === "fe")!; + expect((fe.config as { maxReworkCycles: number }).maxReworkCycles).toBe(5); + }); +}); + +describe("step-review verdict routing", () => { + function templateWithReview(reviewEdges: WorkflowIrEdge[]): WorkflowIrV2 { + const tmpl = { + nodes: [ + { id: "se", kind: "prompt", config: { seam: "step-execute" } }, + { id: "rev", kind: "step-review", config: { type: "plan" } }, + { id: "exit", kind: "prompt" }, + ] as WorkflowIrNode[], + edges: [{ from: "se", to: "rev" }, ...reviewEdges], + }; + return graphWithForeach({ template: tmpl }); + } + + it("rejects step-review missing approve routing", () => { + expect(() => + parseWorkflowIr( + templateWithReview([ + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + { from: "rev", to: "exit", condition: "outcome:other" }, + ]), + ), + ).toThrow(/must route outcome:approve/); + }); + + it("rejects step-review missing revise routing", () => { + expect(() => + parseWorkflowIr( + templateWithReview([{ from: "rev", to: "exit", condition: "outcome:approve" }]), + ), + ).toThrow(/must route outcome:revise/); + }); + + it("accepts approve+revise routing (rethink optional)", () => { + expect(() => + parseWorkflowIr( + templateWithReview([ + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ]), + ), + ).not.toThrow(); + }); + + it("rejects a verdict-authoring step-review inside a split branch (advisory-only)", () => { + const tmpl = { + nodes: [ + { id: "se", kind: "prompt", config: { seam: "step-execute" } }, + { id: "split", kind: "split" }, + { id: "advrev", kind: "step-review", config: { type: "code" } }, + { id: "other", kind: "prompt" }, + { id: "join", kind: "join" }, + { id: "exit", kind: "prompt" }, + ] as WorkflowIrNode[], + edges: [ + { from: "se", to: "split" }, + { from: "split", to: "advrev" }, + { from: "split", to: "other" }, + // advisory review illegally carries approve routing + { from: "advrev", to: "join", condition: "outcome:approve" }, + { from: "other", to: "join" }, + { from: "join", to: "exit" }, + ] as WorkflowIrEdge[], + }; + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow( + /advisory-only/, + ); + }); + + it("accepts an advisory step-review inside a split branch without verdict routing", () => { + const tmpl = { + nodes: [ + { id: "se", kind: "prompt", config: { seam: "step-execute" } }, + { id: "split", kind: "split" }, + { id: "advrev", kind: "step-review", config: { type: "code" } }, + { id: "other", kind: "prompt" }, + { id: "join", kind: "join" }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ] as WorkflowIrNode[], + edges: [ + { from: "se", to: "split" }, + { from: "split", to: "advrev" }, + { from: "split", to: "other" }, + { from: "advrev", to: "join" }, + { from: "other", to: "join" }, + { from: "join", to: "rev" }, + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ] as WorkflowIrEdge[], + }; + expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).not.toThrow(); + }); +}); + +describe("parse-steps validation", () => { + it("rejects parse-steps with empty parser", () => { + const ir = graphWithForeach({}); + (ir.nodes.find((n) => n.id === "ps")!.config as Record).parser = ""; + expect(() => parseWorkflowIr(ir)).toThrow(/non-empty parser/); + }); + + it("rejects parse-steps referencing an undeclared artifact", () => { + const ir = graphWithForeach({}, { artifacts: [{ key: "OTHER.md" }] }); + expect(() => parseWorkflowIr(ir)).toThrow(/undeclared artifact 'PROMPT.md'/); + }); + + it("accepts parse-steps referencing a declared artifact", () => { + const ir = graphWithForeach({}, { artifacts: [{ key: "PROMPT.md", role: "step-source" }] }); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("allows only PROMPT.md when no artifacts are declared", () => { + const ir = graphWithForeach({}); + (ir.nodes.find((n) => n.id === "ps")!.config as Record).artifact = "SPEC.md"; + expect(() => parseWorkflowIr(ir)).toThrow(/only 'PROMPT.md' is allowed/); + }); +}); + +describe("code node validation", () => { + function graphWithCode(config: Record): WorkflowIrV2 { + return v2( + [ + { id: "start", kind: "start" }, + { id: "c", kind: "code", config }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "c" }, + { from: "c", to: "end" }, + ], + ); + } + + it("rejects empty source", () => { + expect(() => parseWorkflowIr(graphWithCode({ source: "" }))).toThrow(/non-empty source/); + }); + + it("rejects source over 64KB", () => { + expect(() => parseWorkflowIr(graphWithCode({ source: "x".repeat(65537) }))).toThrow( + /exceeds 65536/, + ); + }); + + it("accepts valid source and timeout", () => { + expect(() => + parseWorkflowIr(graphWithCode({ source: "export default async () => ({})", timeoutMs: 30000 })), + ).not.toThrow(); + }); + + it("rejects timeoutMs out of range", () => { + expect(() => parseWorkflowIr(graphWithCode({ source: "x", timeoutMs: 999 }))).toThrow( + /timeoutMs must be an integer in 1000\.\.300000/, + ); + expect(() => parseWorkflowIr(graphWithCode({ source: "x", timeoutMs: 300001 }))).toThrow( + /timeoutMs must be an integer in 1000\.\.300000/, + ); + }); +}); + +describe("fields validation", () => { + function graphWithFields(fields: unknown): WorkflowIrV2 { + return v2( + [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + [{ from: "start", to: "end" }], + { fields: fields as WorkflowIrV2["fields"] }, + ); + } + + it("accepts well-formed fields", () => { + expect(() => + parseWorkflowIr( + graphWithFields([ + { id: "sev", name: "Severity", type: "enum", options: [{ value: "lo", label: "Low" }] }, + { id: "note", name: "Note", type: "text", render: { placement: "detail", widget: "textarea" } }, + ]), + ), + ).not.toThrow(); + }); + + it("rejects duplicate field ids", () => { + expect(() => + parseWorkflowIr( + graphWithFields([ + { id: "a", name: "A", type: "string" }, + { id: "a", name: "A2", type: "number" }, + ]), + ), + ).toThrow(/duplicate field id 'a'/); + }); + + it("rejects unknown field type", () => { + expect(() => parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "color" }]))).toThrow( + /unknown type 'color'/, + ); + }); + + it("requires options on enum/multi-enum", () => { + expect(() => parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "enum" }]))).toThrow( + /must declare non-empty options/, + ); + expect(() => + parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "multi-enum", options: [] }])), + ).toThrow(/must declare non-empty options/); + }); + + it("rejects options on non-enum types", () => { + expect(() => + parseWorkflowIr( + graphWithFields([{ id: "a", name: "A", type: "string", options: [{ value: "x", label: "X" }] }]), + ), + ).toThrow(/must not declare options/); + }); + + it("rejects bad render placement / widget", () => { + expect(() => + parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "string", render: { placement: "footer" } }])), + ).toThrow(/render.placement 'footer' is not allowed/); + expect(() => + parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "string", render: { widget: "slider" } }])), + ).toThrow(/render.widget 'slider' is not allowed/); + }); +}); + +describe("downgradeIrToV1IfPure refuses step-inversion features", () => { + it("returns v2 unchanged for a graph with a foreach", () => { + const ir = parseWorkflowIr(graphWithForeach({})) as WorkflowIrV2; + expect(downgradeIrToV1IfPure(ir).version).toBe("v2"); + }); + + it("returns v2 unchanged when fields/artifacts are declared even with pure-v1 nodes", () => { + const ir = v2( + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + { fields: [{ id: "a", name: "A", type: "string" }] }, + ); + expect(downgradeIrToV1IfPure(ir).version).toBe("v2"); + }); +}); + +describe("JSON round-trip stability", () => { + it("re-parses a serialized foreach graph identically", () => { + const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 3 })) as WorkflowIrV2; + const serialized = serializeWorkflowIr(ir); + const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2; + expect(serializeWorkflowIr(reparsed)).toBe(serialized); + }); +}); + +describe("illegal cycle detection (rework exemption)", () => { + it("still rejects a non-rework cycle at the top level", () => { + const ir = v2( + [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt" }, + { id: "b", kind: "prompt" }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "a" }, + { from: "a", to: "b" }, + { from: "b", to: "a" }, + { from: "a", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/illegal cycle/); + }); + + it("does not complain about the rework cycle inside a foreach template", () => { + // graphWithForeach's template has a rework edge rev → se; should parse fine. + expect(() => parseWorkflowIr(graphWithForeach({}))).not.toThrow(); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir-resolver.test.ts b/packages/core/src/__tests__/workflow-ir-resolver.test.ts new file mode 100644 index 0000000000..e4d96cf635 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-resolver.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from "vitest"; +import { getBuiltinWorkflow } from "../builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { + resolveWorkflowIrForTask, + resolveWorkflowIrById, +} from "../workflow-ir-resolver.js"; + +/** A minimal custom IR distinguishable from the built-in default. */ +const CUSTOM_IR: WorkflowIr = { + version: "v2", + name: "custom-flow", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + columns: [{ id: "todo", name: "Todo", traits: [] }], +} as unknown as WorkflowIr; + +function makeStore(opts: { + selection?: { workflowId: string; stepIds: string[] }; + selectionThrows?: boolean; + defs?: Record; +}) { + const getWorkflowDefinition = vi.fn(async (id: string) => opts.defs?.[id]); + const getTaskWorkflowSelection = vi.fn((_taskId: string) => { + if (opts.selectionThrows) throw new Error("boom"); + return opts.selection; + }); + return { getWorkflowDefinition, getTaskWorkflowSelection }; +} + +describe("resolveWorkflowIrForTask", () => { + it("resolves a selection pointing at a custom definition", async () => { + const store = makeStore({ + selection: { workflowId: "wf-custom", stepIds: [] }, + defs: { "wf-custom": { ir: CUSTOM_IR } }, + }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(CUSTOM_IR); + expect(store.getWorkflowDefinition).toHaveBeenCalledWith("wf-custom"); + }); + + it("resolves a built-in workflow id without touching getWorkflowDefinition", async () => { + const store = makeStore({ + selection: { workflowId: "builtin:quick-fix", stepIds: [] }, + }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toEqual(getBuiltinWorkflow("builtin:quick-fix")!.ir); + expect(store.getWorkflowDefinition).not.toHaveBeenCalled(); + }); + + it("falls back to the built-in default when the definition is missing", async () => { + const store = makeStore({ + selection: { workflowId: "wf-gone", stepIds: [] }, + defs: { "wf-gone": undefined }, + }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR); + }); + + it("falls back to the default when there is no selection", async () => { + const store = makeStore({ selection: undefined }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR); + expect(store.getWorkflowDefinition).not.toHaveBeenCalled(); + }); + + it("degrades to the default when the selection lookup throws", async () => { + const store = makeStore({ selectionThrows: true }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR); + }); + + it("caches by workflowId so the definition is fetched once across calls", async () => { + const store = makeStore({ + selection: { workflowId: "wf-custom", stepIds: [] }, + defs: { "wf-custom": { ir: CUSTOM_IR } }, + }); + const cache = new Map(); + const a = await resolveWorkflowIrForTask(store, "t1", cache); + const b = await resolveWorkflowIrForTask(store, "t2", cache); + expect(a).toBe(CUSTOM_IR); + expect(b).toBe(CUSTOM_IR); + expect(store.getWorkflowDefinition).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveWorkflowIrById", () => { + it("parses a raw-string IR from the definition", async () => { + const raw = JSON.stringify(CUSTOM_IR); + const store = makeStore({ defs: { "wf-raw": { ir: raw } } }); + const ir = await resolveWorkflowIrById(store, "wf-raw"); + expect(ir.version).toBe("v2"); + expect(ir.name).toBe("custom-flow"); + }); + + it("returns a cache hit without re-fetching the definition", async () => { + const store = makeStore({ defs: { "wf-custom": { ir: CUSTOM_IR } } }); + const cache = new Map(); + await resolveWorkflowIrById(store, "wf-custom", cache); + await resolveWorkflowIrById(store, "wf-custom", cache); + expect(store.getWorkflowDefinition).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts new file mode 100644 index 0000000000..017678b613 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from "vitest"; +import { + parseWorkflowIr, + serializeWorkflowIr, + downgradeIrToV1IfPure, + WorkflowIrError, + DEFAULT_WORKFLOW_COLUMN_IDS, +} from "../workflow-ir.js"; +import type { + WorkflowIr, + WorkflowIrV1, + WorkflowIrV2, + WorkflowIrNode, + WorkflowIrEdge, +} from "../workflow-ir-types.js"; + +function v2( + columns: WorkflowIrV2["columns"], + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[], +): WorkflowIrV2 { + return { version: "v2", name: "test", columns, nodes, edges }; +} + +const startEnd: WorkflowIrNode[] = [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, +]; + +describe("parseWorkflowIr — v2 columns & placement", () => { + it("parses a v2 graph with columns, placement and a hold node", () => { + const ir = v2( + [ + { id: "intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "work", name: "Work", traits: [] }, + ], + [ + { id: "start", kind: "start", column: "intake" }, + { id: "wait", kind: "hold", column: "intake", config: { release: "manual" } }, + { id: "end", kind: "end", column: "work" }, + ], + [ + { from: "start", to: "wait" }, + { from: "wait", to: "end" }, + ], + ); + const parsed = parseWorkflowIr(ir); + expect(parsed.version).toBe("v2"); + expect(parsed).toEqual(ir); + }); + + it("rejects a node referencing an undefined column id", () => { + const ir = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "end", kind: "end", column: "ghost" }, + ], + [{ from: "start", to: "end" }], + ); + expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(ir)).toThrow(/undefined column 'ghost'/); + }); + + it("rejects duplicate column ids within a workflow", () => { + const ir = v2( + [ + { id: "dup", name: "A", traits: [] }, + { id: "dup", name: "B", traits: [] }, + ], + startEnd, + [{ from: "start", to: "end" }], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/); + }); +}); + +describe("parseWorkflowIr — v1 upgrade", () => { + const v1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { seam: "execute" } }, + { id: "review", kind: "prompt", config: { seam: "review" } }, + { id: "merge", kind: "prompt", config: { seam: "merge" } }, + { id: "custom", kind: "prompt", config: { name: "Plan" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "review", condition: "success" }, + { from: "review", to: "merge", condition: "success" }, + { from: "merge", to: "custom", condition: "success" }, + { from: "custom", to: "end" }, + ], + }; + + it("upgrades a v1 graph to v2 with synthesized default columns", () => { + const parsed = parseWorkflowIr(v1); + expect(parsed.version).toBe("v2"); + if (parsed.version !== "v2") throw new Error("expected v2"); + expect(parsed.columns.map((c) => c.id)).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]); + }); + + it("places nodes by seam (execute→in-progress, review/merge→in-review, others→todo)", () => { + const parsed = parseWorkflowIr(v1); + if (parsed.version !== "v2") throw new Error("expected v2"); + const byId = new Map(parsed.nodes.map((n) => [n.id, n])); + expect(byId.get("execute")?.column).toBe("in-progress"); + expect(byId.get("review")?.column).toBe("in-review"); + expect(byId.get("merge")?.column).toBe("in-review"); + expect(byId.get("custom")?.column).toBe("todo"); + expect(byId.get("start")?.column).toBe("todo"); + }); + + it("upgrade is idempotent (round-trips through serialize unchanged)", () => { + const once = parseWorkflowIr(v1); + const twice = parseWorkflowIr(serializeWorkflowIr(once)); + expect(twice).toEqual(once); + }); + + it("v1 fixtures still parse (back-compat)", () => { + const minimal: WorkflowIr = { + version: "v1", + name: "min", + nodes: startEnd, + edges: [{ from: "start", to: "end" }], + }; + expect(() => parseWorkflowIr(minimal)).not.toThrow(); + }); +}); + +describe("downgradeIrToV1IfPure — rollback compat (#1405)", () => { + const pureV1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { seam: "execute" } }, + { id: "review", kind: "prompt", config: { seam: "review" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "review", condition: "success" }, + { from: "review", to: "end", condition: "success" }, + ], + }; + + it("downgrades an upgraded pure-v1 graph back to the v1 shape", () => { + const upgraded = parseWorkflowIr(pureV1); + expect(upgraded.version).toBe("v2"); + const down = downgradeIrToV1IfPure(upgraded); + expect(down.version).toBe("v1"); + // No synthesized `column` fields leak into the v1 shape. + expect(down.nodes.every((n) => n.column === undefined)).toBe(true); + // Lossless: a v2 binary re-upgrades it to the identical v2 graph. + expect(parseWorkflowIr(serializeWorkflowIr(down))).toEqual(upgraded); + }); + + it("pre-v2 binaries (version-only guard) accept the downgraded shape", () => { + const down = downgradeIrToV1IfPure(parseWorkflowIr(pureV1)); + expect(down.version).toBe("v1"); + // Simulate the pre-v2 hard reject of version !== 'v1'. + expect(() => { + if (down.version !== "v1") throw new WorkflowIrError("unsupported version"); + }).not.toThrow(); + }); + + it("keeps v2 when a v2-only node kind is present", () => { + const ir = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "wait", kind: "hold", column: "todo", config: { release: "manual" } }, + { id: "end", kind: "end", column: "todo" }, + ], + [ + { from: "start", to: "wait" }, + { from: "wait", to: "end" }, + ], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(ir)).version).toBe("v2"); + }); + + it("keeps v2 when columns are customized (rename / extra / applied trait)", () => { + const customName = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id === "todo" ? "Backlog" : id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(customName)).version).toBe("v2"); + + const withTrait = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ + id, + name: id, + traits: id === "todo" ? [{ trait: "intake" }] : [], + })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(withTrait)).version).toBe("v2"); + }); + + it("keeps v2 when a node is placed off its default seam column", () => { + const custom = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + // execute seam defaults to in-progress; place it in done instead. + { id: "exec", kind: "prompt", column: "done", config: { seam: "execute" } }, + { id: "end", kind: "end", column: "todo" }, + ], + [ + { from: "start", to: "exec" }, + { from: "exec", to: "end" }, + ], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(custom)).version).toBe("v2"); + }); + + it("returns a v1 input unchanged", () => { + expect(downgradeIrToV1IfPure(pureV1)).toBe(pureV1); + }); +}); + +describe("parseWorkflowIr — hold release kinds", () => { + const holdCols = [{ id: "c", name: "C", traits: [] }]; + function holdIr(release: unknown): WorkflowIrV2 { + return v2( + holdCols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "h", kind: "hold", column: "c", config: { release } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "h" }, + { from: "h", to: "end" }, + ], + ); + } + + it.each(["manual", "timer", "capacity", "dependency", "external-event"])( + "accepts hold release '%s'", + (release) => { + expect(() => parseWorkflowIr(holdIr(release))).not.toThrow(); + }, + ); + + it("rejects an unknown hold release kind", () => { + expect(() => parseWorkflowIr(holdIr("teleport"))).toThrow(/unknown release kind 'teleport'/); + }); + + it("rejects a hold node missing its release config", () => { + expect(() => parseWorkflowIr(holdIr(undefined))).toThrow(/unknown release kind/); + }); +}); + +describe("parseWorkflowIr — split/join parallelism (KTD-11)", () => { + const cols = [{ id: "c", name: "C", traits: [] }]; + + function p(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[]): WorkflowIrV2 { + return v2(cols, nodes, edges); + } + + it("parses a balanced split → two branches → join", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("parses one nested level of split/join", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "s1", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "s2", kind: "split", column: "c" }, + { id: "n1", kind: "prompt", column: "c" }, + { id: "n2", kind: "prompt", column: "c" }, + { id: "j2", kind: "join", column: "c", config: { mode: "all" } }, + { id: "j1", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "s1" }, + { from: "s1", to: "a" }, + { from: "s1", to: "s2" }, + { from: "a", to: "j1" }, + { from: "s2", to: "n1" }, + { from: "s2", to: "n2" }, + { from: "n1", to: "j2" }, + { from: "n2", to: "j2" }, + { from: "j2", to: "j1" }, + { from: "j1", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("rejects a split without a reachable matching join", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "end" }, + { from: "b", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/no reachable matching join/); + }); + + it("rejects an execute seam node inside a branch (seam-in-branch)", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "exec", kind: "prompt", column: "c", config: { seam: "execute" } }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "exec" }, + { from: "split", to: "b" }, + { from: "exec", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/seam 'execute'.*forbidden inside a parallel branch/); + }); + + it("rejects a merge seam node inside a branch (seam-in-branch)", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "mg", kind: "prompt", column: "c", config: { seam: "merge" } }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "mg" }, + { from: "split", to: "b" }, + { from: "mg", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/seam 'merge'.*forbidden inside a parallel branch/); + }); + + it("rejects quorum(n) with n exceeding the branch count", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: { quorum: 3 } } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/quorum\(3\) exceeds the split's 2 branches/); + }); + + it("accepts quorum(n) with n within the branch count", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: { quorum: 2 } } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + +describe("parseWorkflowIr — version & shape guards", () => { + it("rejects an unknown version", () => { + expect(() => parseWorkflowIr({ version: "v3", name: "x", nodes: startEnd, edges: [] } as unknown as WorkflowIr)).toThrow( + /version must be v1 or v2/, + ); + }); + + it("rejects missing start/end nodes", () => { + const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []); + expect(() => parseWorkflowIr(ir)).toThrow(/exactly one start and one end/); + }); +}); diff --git a/packages/core/src/__tests__/workflow-reconciliation.test.ts b/packages/core/src/__tests__/workflow-reconciliation.test.ts new file mode 100644 index 0000000000..a065cc7d45 --- /dev/null +++ b/packages/core/src/__tests__/workflow-reconciliation.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment node +// +// U5: workflow lifecycle reconciliation — switch / edit / delete with live cards +// (R15, R20). Covers every U5 plan scenario: +// - switch with a same-id column preserves position; +// - switch without one re-homes to the new workflow's entry column AND fires +// the injected abort callback; +// - edit removing an occupied column blocks with per-column occupant counts; +// - the rehomeTo option saves + re-homes all occupants, one audit per card; +// - delete with occupants re-homes to the DEFAULT entry, clears selection, +// preserves task fields; +// - property-style invariant: after any switch/edit/delete sequence every +// task's column exists in its resolved workflow; +// - concurrent move-vs-delete under the task lock ends moved-then-re-homed or +// re-homed, never lost/undefined. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { + OccupiedColumnsError, + setReconciliationAbort, + __resetReconciliationAbortForTests, + type ReconciliationAbortContext, +} from "../workflow-reconciliation.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { resolveEntryColumnId } from "../workflow-reconciliation.js"; + +/** A v2 custom workflow with columns whose ids we control. `entryId` carries the + * intake flag; `cols` lists the column ids in order. Linear graph so it + * compiles. */ +function customIr(name: string, cols: string[], entryId: string): WorkflowIr { + return { + version: "v2", + name, + columns: cols.map((id) => ({ + id, + name: id, + traits: id === entryId ? [{ trait: "intake" }] : [], + })), + nodes: [ + { id: "start", kind: "start", column: entryId }, + { id: "work", kind: "prompt", column: cols[1] ?? entryId, config: { prompt: "do" } }, + { id: "end", kind: "end", column: cols[cols.length - 1] }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +describe("workflow reconciliation (U5)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + __resetReconciliationAbortForTests(); + }); + + afterEach(async () => { + __resetReconciliationAbortForTests(); + await harness.afterEach(); + }); + + /** Move a fresh task (starts in triage) to a default-workflow column. */ + async function seedInColumn(col: "triage" | "todo" | "in-progress"): Promise { + const task = await store.createTask({ description: `seed-${col}` }); + if (col === "triage") return task.id; + await store.moveTask(task.id, "todo", { moveSource: "user" }); + if (col === "todo") return task.id; + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + return task.id; + } + + it("entry column resolves to the intake-flagged column (default workflow = triage)", () => { + expect(resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR)).toBe("triage"); + }); + + describe("(a) workflow switch", () => { + it("preserves position when the new workflow defines the same column id", async () => { + // Custom workflow that ALSO defines "todo" → same-id column, preserved. + const wf = await store.createWorkflowDefinition({ + name: "shares-todo", + ir: customIr("shares-todo", ["todo", "build", "done"], "todo"), + }); + const taskId = await seedInColumn("todo"); + + const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id); + + expect(result.reconciliation?.preserved).toBe(true); + expect(result.reconciliation?.toColumn).toBe("todo"); + const task = await store.getTask(taskId); + expect(task.column).toBe("todo"); + }); + + it("re-homes to the new workflow's entry column when the current column is absent, aborting first", async () => { + const aborts: ReconciliationAbortContext[] = []; + setReconciliationAbort((ctx) => { + aborts.push(ctx); + }); + // Custom workflow has none of the legacy column ids; entry = "intake". + const wf = await store.createWorkflowDefinition({ + name: "fresh", + ir: customIr("fresh", ["intake", "doing", "finished"], "intake"), + }); + const taskId = await seedInColumn("in-progress"); + + const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id); + + expect(result.reconciliation?.preserved).toBe(false); + expect(result.reconciliation?.toColumn).toBe("intake"); + const task = await store.getTask(taskId); + expect(task.column).toBe("intake"); + // Abort callback fired for the in-flight column before the re-home move. + expect(aborts).toHaveLength(1); + expect(aborts[0]).toMatchObject({ taskId, fromColumn: "in-progress", reason: "workflow-switch" }); + }); + + it("re-homes via the default no-op abort when no engine abort is wired", async () => { + const wf = await store.createWorkflowDefinition({ + name: "fresh2", + ir: customIr("fresh2", ["intake", "doing", "finished"], "intake"), + }); + const taskId = await seedInColumn("in-progress"); + const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id); + expect(result.reconciliation?.preserved).toBe(false); + expect((await store.getTask(taskId)).column).toBe("intake"); + }); + }); + + describe("(b) workflow edit removing an occupied column", () => { + it("blocks with per-column occupant counts when no rehomeTo is given", async () => { + const wf = await store.createWorkflowDefinition({ + name: "editable", + ir: customIr("editable", ["intake", "build", "done"], "intake"), + }); + const t1 = await store.createTask({ description: "t1" }); + const t2 = await store.createTask({ description: "t2" }); + await store.selectTaskWorkflowAndReconcile(t1.id, wf.id); // lands in intake + await store.selectTaskWorkflowAndReconcile(t2.id, wf.id); + // Move both into "build" so it's occupied. Custom adjacency is order-derived + // (intake↔build↔done), so intake→build is legal. + await store.moveTask(t1.id, "build", { moveSource: "user" }); + await store.moveTask(t2.id, "build", { moveSource: "user" }); + + // Edit that drops "build". + const nextIr = customIr("editable", ["intake", "done"], "intake"); + await expect(store.updateWorkflowDefinition(wf.id, { ir: nextIr })).rejects.toThrow( + OccupiedColumnsError, + ); + try { + await store.updateWorkflowDefinition(wf.id, { ir: nextIr }); + } catch (err) { + expect(err).toBeInstanceOf(OccupiedColumnsError); + const occ = (err as OccupiedColumnsError).occupancies; + expect(occ).toEqual([{ columnId: "build", count: 2 }]); + } + }); + + it("rehomeTo saves the edit and moves all occupants, emitting one audit per card", async () => { + const wf = await store.createWorkflowDefinition({ + name: "rehomeable", + ir: customIr("rehomeable", ["intake", "build", "done"], "intake"), + }); + const t1 = await store.createTask({ description: "t1" }); + const t2 = await store.createTask({ description: "t2" }); + await store.selectTaskWorkflowAndReconcile(t1.id, wf.id); + await store.selectTaskWorkflowAndReconcile(t2.id, wf.id); + await store.moveTask(t1.id, "build", { moveSource: "user" }); + await store.moveTask(t2.id, "build", { moveSource: "user" }); + + const nextIr = customIr("rehomeable", ["intake", "done"], "intake"); + const saved = await store.updateWorkflowDefinition(wf.id, { ir: nextIr, rehomeTo: "intake" }); + + // Saved IR no longer defines "build". + expect((saved.ir as { columns: { id: string }[] }).columns.map((c) => c.id)).toEqual([ + "intake", + "done", + ]); + expect((await store.getTask(t1.id)).column).toBe("intake"); + expect((await store.getTask(t2.id)).column).toBe("intake"); + }); + + it("does not block when the removed column has no occupants", async () => { + const wf = await store.createWorkflowDefinition({ + name: "empty-col", + ir: customIr("empty-col", ["intake", "build", "done"], "intake"), + }); + const nextIr = customIr("empty-col", ["intake", "done"], "intake"); + await expect(store.updateWorkflowDefinition(wf.id, { ir: nextIr })).resolves.toBeDefined(); + }); + }); + + describe("(c) workflow delete with occupants", () => { + it("re-homes occupants to the default entry, clears selection, preserves fields", async () => { + const wf = await store.createWorkflowDefinition({ + name: "doomed", + ir: customIr("doomed", ["intake", "build", "done"], "intake"), + }); + const t = await store.createTask({ description: "to-rehome" }); + await store.selectTaskWorkflowAndReconcile(t.id, wf.id); + await store.moveTask(t.id, "build", { moveSource: "user" }); + // Stamp a field we expect to survive the re-home (preserveProgress). + await store.updateTask(t.id, { summary: "keep me" }); + + await store.deleteWorkflowDefinition(wf.id); + + const task = await store.getTask(t.id); + // Re-homed to the default workflow's entry column (triage). + expect(task.column).toBe("triage"); + // Selection cleared → resolves to the default workflow now. + expect(store.getTaskWorkflowSelection(t.id)).toBeUndefined(); + // Field preserved. + expect(task.summary).toBe("keep me"); + }); + + it("built-in workflows remain undeletable", async () => { + await expect(store.deleteWorkflowDefinition("builtin:coding")).rejects.toThrow(); + }); + }); + + describe("property-style invariant: no card in an undefined column after any op", () => { + it("every task's column exists in its resolved workflow after switch/edit/delete", async () => { + const wfA = await store.createWorkflowDefinition({ + name: "A", + ir: customIr("A", ["intake", "mid", "out"], "intake"), + }); + const wfB = await store.createWorkflowDefinition({ + name: "B", + ir: customIr("B", ["start-b", "end-b"], "start-b"), + }); + + const ids: string[] = []; + for (let i = 0; i < 4; i++) { + const t = await store.createTask({ description: `prop-${i}` }); + ids.push(t.id); + } + // Switch all to A, scatter into A's columns. + for (const id of ids) await store.selectTaskWorkflowAndReconcile(id, wfA.id); + await store.moveTask(ids[1], "mid", { moveSource: "user" }); + await store.moveTask(ids[2], "mid", { moveSource: "user" }); + await store.moveTask(ids[2], "out", { moveSource: "user" }); + // Switch one to B (different ids → re-home to entry). + await store.selectTaskWorkflowAndReconcile(ids[3], wfB.id); + // Edit A removing "mid" with rehome. + await store.updateWorkflowDefinition(wfA.id, { + ir: customIr("A", ["intake", "out"], "intake"), + rehomeTo: "intake", + }); + // Delete B (re-homes ids[3] to default). + await store.deleteWorkflowDefinition(wfB.id); + + for (const id of ids) { + const task = await store.getTask(id); + const ir = (store as unknown as { resolveTaskWorkflowIrSync: (id: string) => WorkflowIr }) + .resolveTaskWorkflowIrSync(id); + const colIds = (ir as { columns: { id: string }[] }).columns.map((c) => c.id); + expect(colIds).toContain(task.column); + } + }); + }); + + describe("concurrent move-vs-delete under the task lock", () => { + it("ends moved-then-re-homed or re-homed, never lost/undefined", async () => { + const wf = await store.createWorkflowDefinition({ + name: "race", + ir: customIr("race", ["intake", "build", "done"], "intake"), + }); + const t = await store.createTask({ description: "racer" }); + await store.selectTaskWorkflowAndReconcile(t.id, wf.id); + await store.moveTask(t.id, "build", { moveSource: "user" }); + + // Fire a same-workflow move concurrently with the delete. Both serialize + // through the task lock; the task must end in a column defined by its + // resolved workflow (after delete: the default workflow), never undefined. + const movePromise = store + .moveTask(t.id, "done", { moveSource: "user" }) + .catch(() => undefined); + const deletePromise = store.deleteWorkflowDefinition(wf.id); + await Promise.all([movePromise, deletePromise]); + + const task = await store.getTask(t.id); + expect(task.column).toBeTruthy(); + // After delete the task resolves to the default workflow; its column must + // be one the default workflow defines. + const defaultCols = (BUILTIN_CODING_WORKFLOW_IR as { columns: { id: string }[] }).columns.map( + (c) => c.id, + ); + expect(defaultCols).toContain(task.column); + }); + }); +}); diff --git a/packages/core/src/__tests__/workflow-step-instances.test.ts b/packages/core/src/__tests__/workflow-step-instances.test.ts new file mode 100644 index 0000000000..8968f58452 --- /dev/null +++ b/packages/core/src/__tests__/workflow-step-instances.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import type { WorkflowRunStepInstance } from "../types.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +/** + * Step-inversion U4 (KTD-6/KTD-13): persistence groundwork for the foreach + * step-instance region. Covers the workflow_run_step_instances CRUD trio + * (save/load/clear) — upsert-on-conflict, per-run pruning, load ordering — plus + * the raw tasks.customFields JSON round-trip through create/update/get. + * + * The CRUD trio mirrors workflow_run_branches: a `save` is an idempotent UPSERT + * keyed by (taskId, runId, foreachNodeId, stepIndex); `load` returns the run's + * rows ordered by stepIndex; `clear` prunes either everything-but-a-kept-run + * (per-run prune) or, with no runId, every row for the task. + */ + +describe("workflow_run_step_instances CRUD (U4, KTD-6)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + type StepInstanceStore = { + saveWorkflowRunStepInstance(state: WorkflowRunStepInstance): void; + loadWorkflowRunStepInstances(taskId: string, runId: string): WorkflowRunStepInstance[]; + clearWorkflowRunStepInstances(taskId: string, keepRunId?: string): void; + }; + const sis = (): StepInstanceStore => store as unknown as StepInstanceStore; + + function rawCount(taskId: string): number { + const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; + const row = db + .prepare("SELECT COUNT(*) AS c FROM workflow_run_step_instances WHERE taskId = ?") + .get(taskId) as { c: number }; + return row.c; + } + + function makeInstance(overrides: Partial = {}): WorkflowRunStepInstance { + return { + taskId: "T-1", + runId: "r1", + foreachNodeId: "fe", + stepIndex: 0, + pinnedStepCount: 3, + currentNodeId: "n1", + status: "in-progress", + baselineSha: "abc123", + checkpointId: "ckpt-1", + reworkCount: 0, + branchName: null, + integratedAt: null, + updatedAt: "2026-06-04T00:00:00.000Z", + ...overrides, + }; + } + + it("round-trips a full instance row through save → load", async () => { + const t = await store.createTask({ description: "stepped" }); + const inst = makeInstance({ + taskId: t.id, + branchName: "step/0", + integratedAt: "2026-06-04T01:00:00.000Z", + status: "completed", + reworkCount: 2, + }); + sis().saveWorkflowRunStepInstance(inst); + + const [loaded] = sis().loadWorkflowRunStepInstances(t.id, "r1"); + expect(loaded.taskId).toBe(t.id); + expect(loaded.runId).toBe("r1"); + expect(loaded.foreachNodeId).toBe("fe"); + expect(loaded.stepIndex).toBe(0); + expect(loaded.pinnedStepCount).toBe(3); + expect(loaded.currentNodeId).toBe("n1"); + expect(loaded.status).toBe("completed"); + expect(loaded.baselineSha).toBe("abc123"); + expect(loaded.checkpointId).toBe("ckpt-1"); + expect(loaded.reworkCount).toBe(2); + expect(loaded.branchName).toBe("step/0"); + expect(loaded.integratedAt).toBe("2026-06-04T01:00:00.000Z"); + expect(typeof loaded.updatedAt).toBe("string"); + }); + + it("save UPSERTS on (taskId, runId, foreachNodeId, stepIndex) conflict", async () => { + const t = await store.createTask({ description: "upsert" }); + sis().saveWorkflowRunStepInstance( + makeInstance({ taskId: t.id, stepIndex: 0, currentNodeId: "n1", status: "in-progress", reworkCount: 0 }), + ); + // Same PK — overwrites in place, not a second row. + sis().saveWorkflowRunStepInstance( + makeInstance({ taskId: t.id, stepIndex: 0, currentNodeId: "n5", status: "completed", reworkCount: 1 }), + ); + // Different stepIndex — a new row. + sis().saveWorkflowRunStepInstance( + makeInstance({ taskId: t.id, stepIndex: 1, currentNodeId: "n2", status: "pending" }), + ); + + expect(rawCount(t.id)).toBe(2); + const loaded = sis().loadWorkflowRunStepInstances(t.id, "r1"); + const step0 = loaded.find((row) => row.stepIndex === 0); + expect(step0?.currentNodeId).toBe("n5"); + expect(step0?.status).toBe("completed"); + expect(step0?.reworkCount).toBe(1); + }); + + it("persists nullable anchors as null and reads them back as null", async () => { + const t = await store.createTask({ description: "nulls" }); + sis().saveWorkflowRunStepInstance( + makeInstance({ + taskId: t.id, + currentNodeId: null, + baselineSha: null, + checkpointId: null, + branchName: null, + integratedAt: null, + status: "pending", + }), + ); + const [loaded] = sis().loadWorkflowRunStepInstances(t.id, "r1"); + expect(loaded.currentNodeId).toBeNull(); + expect(loaded.baselineSha).toBeNull(); + expect(loaded.checkpointId).toBeNull(); + expect(loaded.branchName).toBeNull(); + expect(loaded.integratedAt).toBeNull(); + }); + + it("loadWorkflowRunStepInstances returns the run ordered by stepIndex", async () => { + const t = await store.createTask({ description: "ordered" }); + // Insert out of order. + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, stepIndex: 2, currentNodeId: "n2" })); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, stepIndex: 0, currentNodeId: "n0" })); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, stepIndex: 1, currentNodeId: "n1" })); + + const loaded = sis().loadWorkflowRunStepInstances(t.id, "r1"); + expect(loaded.map((row) => row.stepIndex)).toEqual([0, 1, 2]); + }); + + it("loadWorkflowRunStepInstances scopes to the requested run only", async () => { + const t = await store.createTask({ description: "scoped" }); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r1", stepIndex: 0 })); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r2", stepIndex: 0 })); + expect(sis().loadWorkflowRunStepInstances(t.id, "r1").length).toBe(1); + expect(sis().loadWorkflowRunStepInstances(t.id, "r2").length).toBe(1); + }); + + it("clear with keepRunId prunes every other run, keeps the kept run", async () => { + const t = await store.createTask({ description: "prune" }); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "old", stepIndex: 0 })); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "old", stepIndex: 1 })); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "cur", stepIndex: 0 })); + + sis().clearWorkflowRunStepInstances(t.id, "cur"); + + expect(rawCount(t.id)).toBe(1); + expect(sis().loadWorkflowRunStepInstances(t.id, "old").length).toBe(0); + expect(sis().loadWorkflowRunStepInstances(t.id, "cur").length).toBe(1); + }); + + it("clear with no keepRunId prunes all rows for the task", async () => { + const t = await store.createTask({ description: "wipe" }); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r1", stepIndex: 0 })); + sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r2", stepIndex: 0 })); + + sis().clearWorkflowRunStepInstances(t.id); + + expect(rawCount(t.id)).toBe(0); + }); +}); + +describe("tasks.customFields JSON round-trip under a fielded workflow (U11/KTD-13)", () => { + // U11 behavior change vs. U4: customFields is no longer an opaque whole-object + // round-trip — every write is now validated against the task's workflow field + // schema through the single store authority (task-fields.ts). The default + // workflow declares no fields, so the original U4 tests (which wrote arbitrary + // keys onto a default-workflow task) would now be rejected with + // `no-fields-defined`. They are reworked here to attach a workflow that + // declares the fields under test, and `updateTask` is now a MERGE-with-delete + // patch (not whole-object replacement). The zero-fields rejection path is + // covered in task-fields.test.ts. + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + // A v2 workflow declaring the fields exercised below. + const fieldedIr = (): WorkflowIr => + ({ + version: "v2", + name: "fielded", + columns: [ + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "done", name: "done", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + fields: [ + { + id: "severity", + name: "Severity", + type: "enum", + options: [ + { value: "high", label: "High" }, + { value: "low", label: "Low" }, + ], + }, + { id: "points", name: "Points", type: "number" }, + { id: "flagged", name: "Flagged", type: "boolean" }, + { + id: "tags", + name: "Tags", + type: "multi-enum", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + { id: "keep", name: "Keep", type: "string" }, + { id: "a", name: "A", type: "number" }, + { id: "b", name: "B", type: "number" }, + ], + }) as unknown as WorkflowIr; + + let workflowId: string; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + const def = await (store as any).createWorkflowDefinition({ name: "Fielded", ir: fieldedIr() }); + workflowId = def.id; + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function fieldedTask(description: string) { + const t = await store.createTask({ description }); + await (store as any).selectTaskWorkflow(t.id, workflowId); + return t; + } + + it("a freshly created task has no customFields (legacy-shape default)", async () => { + const t = await store.createTask({ description: "no fields" }); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({}); + }); + + it("round-trips a validated customFields object through updateTask → getTask", async () => { + const t = await fieldedTask("fielded"); + await store.updateTask(t.id, { + customFields: { severity: "high", points: 3, flagged: true, tags: ["a", "b"] }, + }); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({ severity: "high", points: 3, flagged: true, tags: ["a", "b"] }); + }); + + it("updateTask MERGES the customFields patch (U11 change from U4's whole-object replace)", async () => { + const t = await fieldedTask("merge"); + await store.updateTask(t.id, { customFields: { a: 1, b: 2 } }); + await store.updateTask(t.id, { customFields: { a: 9 } }); + const got = await store.getTask(t.id); + // U11 merge semantics: `b` survives, `a` is overwritten. (U4 replaced wholesale.) + expect(got?.customFields).toEqual({ a: 9, b: 2 }); + }); + + it("null in the patch deletes that field's value", async () => { + const t = await fieldedTask("delete"); + await store.updateTask(t.id, { customFields: { a: 1, b: 2 } }); + await store.updateTask(t.id, { customFields: { a: null } }); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({ b: 2 }); + }); + + it("leaves customFields untouched when an unrelated field is updated", async () => { + const t = await fieldedTask("untouched"); + await store.updateTask(t.id, { customFields: { keep: "me" } }); + await store.updateTask(t.id, { summary: "an unrelated change" }); + const got = await store.getTask(t.id); + expect(got?.customFields).toEqual({ keep: "me" }); + }); +}); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 663dc85a4b..381d78156e 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -1,15 +1,54 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; +/** + * The built-in default workflow as a v2 IR. Its six columns have ids that are + * EXACTLY the legacy enum values in legacy order (KTD-1), so a task with no + * workflow selection resolves here and its stored `column` value is already a + * valid column id — migration rewrites zero task rows. + * + * Trait ids are plain strings (the trait registry ships in U2); the mapping + * reproduces legacy behavior verbatim (R12): + * triage = intake + * todo = hold(capacity) + reset-on-entry + * in-progress = wip + abort-on-exit + timing + * in-review = merge-blocker + stall-detection + merge + * done = complete + * archived = archived + * + * The seam nodes (execute/review/merge) are placed in their columns; the graph + * walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph + * executor continues to drive execute → review → merge unchanged. + */ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { - version: "v1", + version: "v2", name: "builtin-coding-workflow", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { + id: "todo", + name: "Todo", + traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }], + }, + { + id: "in-progress", + name: "In progress", + traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }], + }, + { + id: "in-review", + name: "In review", + traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }], + }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + { id: "archived", name: "Archived", traits: [{ trait: "archived" }] }, + ], nodes: [ - { id: "start", kind: "start" }, - { id: "execute", kind: "prompt", config: { seam: "execute" } }, - { id: "review", kind: "prompt", config: { seam: "review" } }, - { id: "merge", kind: "prompt", config: { seam: "merge" } }, - { id: "end", kind: "end" }, + { id: "start", kind: "start", column: "triage" }, + { id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } }, + { id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } }, + { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, + { id: "end", kind: "end", column: "done" }, ], edges: [ { from: "start", to: "execute" }, diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts new file mode 100644 index 0000000000..150e47f96e --- /dev/null +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -0,0 +1,151 @@ +import type { WorkflowIr } from "./workflow-ir-types.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; + +/** + * The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step + * inversion and the parity-comparison subject for the engine's + * `stepwise-workflow-parity.test.ts`. + * + * Unlike the default `builtin-coding-workflow-ir` (which keeps a single monolithic + * `execute` seam and is the byte-identity parity oracle, KTD-1), this workflow + * models per-step policy explicitly as graph structure: + * + * plan seam + * → parse-steps(PROMPT.md, step-headings) (KTD-12: graph-native parse) + * → foreach(task-steps, sequential, shared) { (KTD-3: runtime expansion) + * step-execute (KTD-2: run one step) + * → step-review(code): (KTD-4: verdicts as edges) + * approve → step-done (template exit) (APPROVE auto-completes) + * revise → rework back to step-execute (revise in place, no reset) + * rethink → rework back to step-execute (reset semantics handler-side) + * unavailable → (advisory) routes onward + * } + * rework-exhausted → hold(manual) (KTD-5: bounded escalation) + * → review seam + * → merge seam + * + * The columns/traits are identical to the default builtin so the full lifecycle + * (merge-blocker, capacity, hold, complete, archived) behaves exactly as it does + * for the default workflow — only the in-progress step modeling differs. + * + * It declares its step-source artifact (KTD-12): PROMPT.md produced by the + * planning seam. The IR is v2-only (foreach/step-review/parse-steps are v2 node + * kinds), so `downgradeIrToV1IfPure` refuses it and the flag-OFF rollback contract + * (KTD-8) is preserved automatically. + */ +const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { + version: "v2", + name: "builtin-stepwise-coding", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { + id: "todo", + name: "Todo", + traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }], + }, + { + id: "in-progress", + name: "In progress", + traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }], + }, + { + id: "in-review", + name: "In review", + traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }], + }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + { id: "archived", name: "Archived", traits: [{ trait: "archived" }] }, + ], + // KTD-12: PROMPT.md is the planning-produced step-source artifact this workflow + // parses into task steps. + artifacts: [{ key: "PROMPT.md", title: "Plan", producedBy: "planning", role: "step-source" }], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + // Planning seam: produces PROMPT.md (the declared step-source artifact). + { id: "plan", kind: "prompt", column: "in-progress", config: { seam: "planning" } }, + // KTD-12: parse the planned PROMPT.md into the task step list. This node must + // dominate the foreach (validator-enforced). + { + id: "parse", + kind: "parse-steps", + column: "in-progress", + config: { artifact: "PROMPT.md", parser: "step-headings" }, + }, + // KTD-3: runtime-expanding per-step region. Sequential + shared isolation is + // the default baseline physics (one step at a time in the task's worktree). + { + id: "steps", + kind: "foreach", + column: "in-progress", + config: { + source: "task-steps", + mode: "sequential", + isolation: "shared", + maxReworkCycles: 3, + template: { + nodes: [ + // KTD-2: run exactly this step inside the task's session/worktree. + { id: "step-execute", kind: "prompt", config: { seam: "step-execute" } }, + // KTD-4: per-step code review; verdicts become outcome edges. + { id: "step-review", kind: "step-review", config: { type: "code" } }, + // Template exit (the single sink the validator requires): a config-less + // gate is a pure pass-through (createGateHandler → success), so APPROVE + // routes here and the instance exits. The step is already marked done by + // the step-review APPROVE verdict (projection authority, KTD-4/KTD-7). + { id: "step-done", kind: "gate", config: {} }, + ], + edges: [ + { from: "step-execute", to: "step-review", condition: "success" }, + // APPROVE → template exit (step-done). The step-review verdict already + // marked the step done through the projection. + { from: "step-review", to: "step-done", condition: "outcome:approve" }, + // REVISE → rework back to step-execute, revise in place (no reset). + { + from: "step-review", + to: "step-execute", + condition: "outcome:revise", + kind: "rework", + }, + // RETHINK → rework back to step-execute; the traversal triggers + // resetStepToBaseline (reset semantics are handler-side, KTD-4/U5). + { + from: "step-review", + to: "step-execute", + condition: "outcome:rethink", + kind: "rework", + }, + ], + }, + }, + }, + // KTD-5: rework exhaustion escalates to a manual hold (a human releases it). + { id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } }, + { id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } }, + { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "plan" }, + { from: "plan", to: "parse", condition: "success" }, + { from: "plan", to: "end", condition: "failure" }, + { from: "parse", to: "steps", condition: "success" }, + // parse-steps no-steps defaults to success; route it explicitly to the foreach + // (zero steps → foreach no-ops through its success edge, KTD-8/R8). + { from: "parse", to: "steps", condition: "outcome:no-steps" }, + { from: "parse", to: "end", condition: "failure" }, + { from: "parse", to: "end", condition: "outcome:parse-error" }, + { from: "steps", to: "review", condition: "success" }, + // KTD-5: bounded rework exhaustion → manual hold; release re-enters review. + { from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" }, + { from: "rework-hold", to: "review", condition: "success" }, + { from: "steps", to: "end", condition: "failure" }, + { from: "review", to: "merge", condition: "success" }, + { from: "review", to: "end", condition: "failure" }, + { from: "merge", to: "end", condition: "success" }, + { from: "merge", to: "end", condition: "failure" }, + ], +}; + +export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr( + RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR, +); diff --git a/packages/core/src/builtin-traits.ts b/packages/core/src/builtin-traits.ts new file mode 100644 index 0000000000..957b6735cc --- /dev/null +++ b/packages/core/src/builtin-traits.ts @@ -0,0 +1,270 @@ +/** + * The 14 built-in traits (U2, R7) from the Trait Vocabulary table. Behavior for + * each trait lands in later units; here we ship the definitions (flags + config + * schema + hook descriptors) and register them into the shared trait registry. + * + * The `merge` trait's config schema is a STUB here (shape only — strategy / + * fileScope / squash / conflictStrategy); its behavior is U7. + * + * Registration is idempotent at module scope (registered once on import). Tests + * that need a clean slate use `__resetTraitRegistryForTests()` + + * `registerBuiltinTraits(registry)`. + */ + +import type { TraitDefinition } from "./trait-types.js"; +import { TraitRegistry, getTraitRegistry } from "./trait-registry.js"; + +/** The ids of the 14 built-in traits, in vocabulary-table order. */ +export const BUILTIN_TRAIT_IDS = [ + "intake", + "complete", + "archived", + "merge-blocker", + "wip", + "hold", + "human-review", + "gate", + "merge", + "abort-on-exit", + "reset-on-entry", + "timing", + "stall-detection", + "notify", +] as const; + +export type BuiltinTraitId = (typeof BUILTIN_TRAIT_IDS)[number]; + +/** The built-in trait definitions. */ +export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [ + { + id: "intake", + name: "Intake", + description: "Where new cards land; exactly one per workflow.", + builtin: true, + flags: { intake: true }, + configSchema: { + fields: [{ key: "autoTriage", type: "boolean", description: "Auto-triage new cards" }], + }, + }, + { + id: "complete", + name: "Complete", + description: "Terminal success; satisfies dependencies. Restricted flag.", + builtin: true, + flags: { complete: true }, + }, + { + id: "archived", + name: "Archived", + description: "Hidden from board; global semantics. Restricted flag.", + builtin: true, + flags: { archived: true, hiddenFromBoard: true }, + }, + { + id: "merge-blocker", + name: "Merge blocker", + description: + "Generalized FN-5147: entry to complete-bound columns blocked until the merge-class node completed.", + builtin: true, + flags: { mergeBlocker: true }, + hooks: { guard: true }, + }, + { + id: "wip", + name: "WIP / capacity", + description: "Substrate-enforced in-txn capacity limit; never bypassable (KTD-10).", + builtin: true, + flags: { countsTowardWip: true }, + configSchema: { + fields: [ + { key: "limit", type: "number", required: true, description: "Max concurrent cards" }, + { key: "countPending", type: "boolean", description: "Count mid-transition cards" }, + ], + }, + }, + { + id: "hold", + name: "Hold", + description: "Passive dwell; released by a configured condition.", + builtin: true, + flags: { hold: true }, + hooks: { releaseCondition: true }, + configSchema: { + fields: [ + { + key: "release", + type: "enum", + required: true, + enumValues: ["manual", "timer", "capacity", "dependency", "external-event"], + description: "Release condition kind (matches WorkflowHoldRelease)", + }, + ], + }, + }, + { + id: "human-review", + name: "Human review", + description: + "Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). Not on the default workflow.", + builtin: true, + flags: { humanReview: true }, + hooks: { guard: true }, + configSchema: { + fields: [ + { key: "approvers", type: "array", description: "Allowed approver ids" }, + { key: "checklist", type: "array", description: "Required checklist items" }, + ], + }, + }, + { + id: "gate", + name: "Gate", + description: + "Workflow-step gate semantics generalized to columns; the plugin-facing gate surface. Blocking gates fail closed.", + builtin: true, + flags: { gate: true }, + hooks: { gate: true }, + configSchema: { + fields: [ + { + key: "gateMode", + type: "enum", + required: true, + enumValues: ["blocking", "advisory"], + description: "Blocking gates fail closed; advisory gates record and allow", + }, + { key: "prompt", type: "string", description: "Gate prompt" }, + { key: "script", type: "string", description: "Gate script" }, + ], + }, + }, + { + id: "merge", + name: "Merge", + description: + "Enqueues onto the merge-request queue; configures merge policy (U7). The lost-work guard trio stays capability-level and is unreachable from this config (KTD-6).", + builtin: true, + flags: { mergeOrchestration: true }, + hooks: { onEnter: true, onExit: true }, + configSchema: { + fields: [ + { + key: "strategy", + type: "enum", + // Direct-merge commit strategies (`DirectMergeCommitStrategy`) plus + // `pr-only` (maps onto `mergeStrategy: "pull-request"`). Absent → + // settings read-through (back-compat for the default workflow). + enumValues: ["always-squash", "auto", "always-rebase", "pr-only"], + description: "Merge strategy", + }, + { + key: "fileScope", + type: "enum", + // strict = throw on zero-overlap (today); warn = log + proceed (audit + // carries the violating file list); off = skip the throw + emit one + // per-merge "scope enforcement disabled" audit (per-task scopeOverride + // is a documented no-op here); custom = evaluate `rules` in place of + // the task's File Scope section. + enumValues: ["strict", "warn", "off", "custom"], + description: "File-scope enforcement mode", + }, + { + key: "rules", + type: "array", + description: "Custom file-scope glob/path rules (used when fileScope === 'custom')", + }, + { key: "squash", type: "boolean", description: "Squash posture" }, + { + key: "conflictStrategy", + type: "string", + description: "Conflict resolution strategy", + }, + ], + }, + }, + { + id: "abort-on-exit", + name: "Abort on exit", + description: "Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9).", + builtin: true, + flags: { abortOnExit: true }, + hooks: { onExit: true }, + configSchema: { + fields: [ + { + key: "direction", + type: "enum", + enumValues: ["backward", "any"], + description: "Which exits trigger abort", + }, + { key: "confirm", type: "boolean", description: "Require user confirmation" }, + ], + }, + }, + { + id: "reset-on-entry", + name: "Reset on entry", + description: "Legacy reopen-to-todo field/step resets.", + builtin: true, + flags: { resetOnEntry: true }, + hooks: { onEnter: true }, + configSchema: { + fields: [ + { key: "preserveProgress", type: "boolean", description: "Keep progress fields" }, + ], + }, + }, + { + id: "timing", + name: "Timing", + description: "cumulativeActiveMs accounting generalized.", + builtin: true, + flags: { timing: true }, + hooks: { onEnter: true, onExit: true }, + }, + { + id: "stall-detection", + name: "Stall detection", + description: "In-review stall signals generalized to any column (sweep-evaluated).", + builtin: true, + flags: { stallDetection: true }, + configSchema: { + fields: [ + { key: "timeoutMs", type: "number", required: true, description: "Stall threshold" }, + { + key: "action", + type: "enum", + enumValues: ["annotate", "notify", "move"], + description: "Action on stall", + }, + ], + }, + }, + { + id: "notify", + name: "Notify", + description: "Basic notifications; richer notification traits are the canonical plugin example.", + builtin: true, + flags: { notify: true }, + hooks: { onEnter: true, onExit: true }, + configSchema: { + fields: [ + { key: "events", type: "array", description: "Events to notify on" }, + { key: "channel", type: "string", description: "Notification channel" }, + ], + }, + }, +]; + +/** Register all 14 built-in traits into the given registry (defaults to the + * shared registry). Idempotent guard for the shared instance lives in the + * module-scope registration below. */ +export function registerBuiltinTraits(registry: TraitRegistry = getTraitRegistry()): void { + for (const def of BUILTIN_TRAIT_DEFINITIONS) { + if (registry.has(def.id)) continue; + registry.register(def); + } +} + +// Register into the shared registry on import (idempotent via `has`). +registerBuiltinTraits(); diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index b8ecafc400..62e734cf44 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -1,3 +1,4 @@ +import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; import type { WorkflowDefinition } from "./workflow-definition-types.js"; import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; @@ -139,6 +140,32 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ }, ], }), + // The stepwise coding workflow (KTD-9) — step inversion as authored graph + // structure (parse-steps → foreach{ step-execute → step-review } → review → + // merge). Authored directly as a v2 IR (the `linear` helper only builds simple + // pipelines); it is read-only like every built-in. Requires the + // `workflowGraphExecutor` flag at run time (foreach/step-review/parse-steps are + // interpreter-only node kinds, KTD-8); under the flag-off compile path its + // step-inversion nodes are skipped, the same posture as the other seam nodes. + { + id: "builtin:stepwise-coding", + name: "Stepwise coding (built-in)", + description: + "Per-step plan, execute, and review modeled as graph structure: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework. Requires the workflow graph executor.", + ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, + layout: { + start: { x: 60, y: 160 }, + plan: { x: 230, y: 160 }, + parse: { x: 400, y: 160 }, + steps: { x: 570, y: 160 }, + "rework-hold": { x: 570, y: 320 }, + review: { x: 740, y: 160 }, + merge: { x: 910, y: 160 }, + end: { x: 1080, y: 160 }, + }, + createdAt: BUILTIN_TS, + updatedAt: BUILTIN_TS, + }, ]; const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf])); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index f9ea699ad4..6403a186f1 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 105; +const SCHEMA_VERSION = 108; export { SCHEMA_VERSION }; @@ -322,7 +322,9 @@ CREATE TABLE IF NOT EXISTS tasks ( checkoutLeaseRenewedAt TEXT, checkoutLeaseEpoch INTEGER DEFAULT 0, deletedAt TEXT, - allowResurrection INTEGER DEFAULT 0 + allowResurrection INTEGER DEFAULT 0, + transitionPending TEXT, + customFields TEXT DEFAULT '{}' ); -- Config table (single row with project settings) @@ -574,6 +576,46 @@ CREATE TABLE IF NOT EXISTS completion_handoff_markers ( ); CREATE INDEX IF NOT EXISTS idx_completion_handoff_markers_acceptedAt ON completion_handoff_markers(acceptedAt); +-- Per-branch run state for concurrent workflow fan-out/join (U13, KTD-11/R21). +-- Reconstructible per ADR-0001: a crashed parallel run resumes each branch from +-- its persisted node; completed branches are not re-run. Additive-only. +CREATE TABLE IF NOT EXISTS workflow_run_branches ( + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + runId TEXT NOT NULL, + branchId TEXT NOT NULL, + currentNodeId TEXT NOT NULL, + status TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (taskId, runId, branchId) +); +CREATE INDEX IF NOT EXISTS idx_workflow_run_branches_task_run ON workflow_run_branches(taskId, runId); + +-- Per-step-instance run state for the step-inversion foreach region (step-inversion +-- U4, KTD-6). One row per expanded step instance inside a foreach region; resume +-- reconstructs the instance set from pinnedStepCount + persisted currentNodeId/ +-- reworkCount without re-running completed instances. baselineSha/checkpointId +-- persist the RETHINK reset anchors (previously in-memory, lost on restart). +-- branchName/integratedAt and the "awaiting-integration" status serve parallel +-- mode (KTD-11) and are null/unused at concurrency 1. Additive-only, reconstructible. +-- status ∈ "pending" | "in-progress" | "awaiting-integration" | "completed" | "failed". +CREATE TABLE IF NOT EXISTS workflow_run_step_instances ( + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + runId TEXT NOT NULL, + foreachNodeId TEXT NOT NULL, + stepIndex INTEGER NOT NULL, + pinnedStepCount INTEGER NOT NULL, + currentNodeId TEXT, + status TEXT NOT NULL, + baselineSha TEXT, + checkpointId TEXT, + reworkCount INTEGER NOT NULL DEFAULT 0, + branchName TEXT, + integratedAt TEXT, + updatedAt TEXT NOT NULL, + PRIMARY KEY (taskId, runId, foreachNodeId, stepIndex) +); +CREATE INDEX IF NOT EXISTS idx_workflow_run_step_instances_task_run ON workflow_run_step_instances(taskId, runId); + -- Task documents (key-value store per task with revision tracking) CREATE TABLE IF NOT EXISTS task_documents ( id TEXT PRIMARY KEY, @@ -4181,6 +4223,74 @@ export class Database { }); } + // Migration 106: Crash-safe transition marker (workflow-columns U3). Stores + // JSON {toColumn, hooksRemaining, startedAt} written in the same txn as a + // column change; recovery re-runs the remaining idempotent post-commit hooks + // and clears it. Additive-only, nullable, no backfill — existing rows have + // no in-flight transition. + if (version < 106) { + this.applyMigration(106, () => { + this.addColumnIfMissing("tasks", "transitionPending", "TEXT"); + }); + } + + // Migration 107: Per-branch run state for concurrent workflow fan-out/join + // (workflow-columns U13, KTD-11/R21). Stores {taskId, runId, branchId, + // currentNodeId, status} so a crashed parallel run resumes each branch from + // its persisted node without re-running completed branches. Additive-only, + // idempotent (table-exists guard); no backfill. + if (version < 107) { + this.applyMigration(107, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS workflow_run_branches ( + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + runId TEXT NOT NULL, + branchId TEXT NOT NULL, + currentNodeId TEXT NOT NULL, + status TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (taskId, runId, branchId) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_run_branches_task_run ON workflow_run_branches(taskId, runId); + `); + }); + } + + // Migration 108: Step-inversion persistence (step-inversion U4, KTD-6/KTD-13). + // Adds workflow_run_step_instances — one row per expanded step instance inside a + // foreach region — so a crashed/restarted run reconstructs the instance set from + // pinnedStepCount + persisted currentNodeId/reworkCount, and the RETHINK reset + // anchors (baselineSha/checkpointId) survive restart (previously in-memory Maps). + // branchName/integratedAt + "awaiting-integration" status serve parallel mode + // (KTD-11; null/unused at concurrency 1). Also adds tasks.customFields (KTD-13), + // the JSON store for workflow-defined custom task field values. Additive-only, + // idempotent (table-exists / addColumnIfMissing guards); no backfill. + // status ∈ "pending" | "in-progress" | "awaiting-integration" | "completed" | "failed". + if (version < 108) { + this.applyMigration(108, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS workflow_run_step_instances ( + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + runId TEXT NOT NULL, + foreachNodeId TEXT NOT NULL, + stepIndex INTEGER NOT NULL, + pinnedStepCount INTEGER NOT NULL, + currentNodeId TEXT, + status TEXT NOT NULL, + baselineSha TEXT, + checkpointId TEXT, + reworkCount INTEGER NOT NULL DEFAULT 0, + branchName TEXT, + integratedAt TEXT, + updatedAt TEXT NOT NULL, + PRIMARY KEY (taskId, runId, foreachNodeId, stepIndex) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_run_step_instances_task_run ON workflow_run_step_instances(taskId, runId); + `); + this.addColumnIfMissing("tasks", "customFields", "TEXT DEFAULT '{}'"); + }); + } + } /** diff --git a/packages/core/src/default-workflow-hooks.ts b/packages/core/src/default-workflow-hooks.ts new file mode 100644 index 0000000000..c3dc13495d --- /dev/null +++ b/packages/core/src/default-workflow-hooks.ts @@ -0,0 +1,313 @@ +/** + * Default-workflow trait hook implementations (U4). + * + * The legacy per-column side effects of `moveTaskInternal` — timing / + * `cumulativeActiveMs` accounting, reopen field/step resets, autoMerge stamping + * + merge-queue enqueue, and abort-on-exit (hard-cancel incl. `userPaused` only + * for user-source moves) — become the default workflow's trait hook + * implementations, registered through U2's DI seam (`registerTraitHookImpl`). + * + * IMPORTANT (per U4): this is the FLAG-ON path. The legacy inline code in + * `store.ts` is NOT deleted — it IS the flag-off path. The implementations here + * are a deliberate parallel of that inline logic so the two paths can be parity- + * checked against each other; "moved, not duplicated" applies to the flag-ON + * path only. + * + * Hook classes (KTD-2): + * - guard (sync, in-lock): merge-blocker, human-review. Implemented as the + * `evaluateDefaultWorkflowGuards` reader; pure DB-free reads off the task. + * - onEnter / onExit (mutating, applied in-lock to the in-memory task before + * the commit for field effects; queue effects run in-txn): timing, + * reset-on-entry, abort-on-exit, merge. + * + * Worktree allocation is explicitly NOT a hook (it stays a substrate capability + * invoked before the move; see store.ts) — there is no `allocateWorktree` hook + * here by design. + * + * The hooks are registered into the shared trait registry on `init` via + * `registerDefaultWorkflowHooks()` (idempotent). They are resolved through + * `getTraitRegistry().resolveTraitHook(...)` so a missing registration degrades + * to a no-op + audit warning rather than crashing. + */ + +import { getTraitRegistry } from "./trait-registry.js"; +import type { TraitAuditWarning } from "./trait-registry.js"; +import { getTaskMergeBlocker } from "./task-merge.js"; +import type { Settings, Task } from "./types.js"; + +// ── Guard evaluation (sync, in-lock) ───────────────────────────────────────── + +/** A guard verdict: undefined = allow; a string reason = reject. */ +export type GuardVerdict = string | undefined; + +/** + * Evaluate the default workflow's sync guards for a move. Reproduces the legacy + * `getTaskMergeBlocker` gate on `in-review → done`. (The default workflow does + * not carry the human-review trait — see the Trait Vocabulary note — so there + * is no human-review guard on this workflow.) + * + * `bypassGuards` (engine-sourced moves, KTD-9) skips guards entirely — the + * caller is responsible for honoring that; this function still computes the + * verdict so callers can choose. The store only consults it when not bypassing. + */ +export function evaluateMergeBlockerGuard( + task: Pick, + fromColumn: string, + toColumn: string, +): GuardVerdict { + if (fromColumn === "in-review" && toColumn === "done") { + return getTaskMergeBlocker(task); + } + return undefined; +} + +// ── Move-effect context ─────────────────────────────────────────────────────── + +/** Side-effect callbacks the store provides so the hooks stay engine-free and + * DB-handle-free; the store wires these to its in-txn / post-commit machinery. */ +export interface DefaultWorkflowMoveContext { + task: Task; + fromColumn: string; + toColumn: string; + moveSource: "user" | "engine" | "scheduler"; + /** True when guards + abort-on-exit are bypassed (engine/recovery, KTD-9). */ + bypassGuards: boolean; + movedAt: string; + /** Settings snapshot for autoMerge stamping (only read when entering review). */ + settings: Pick | undefined; + /** Move options that influence reopen/timing semantics. */ + options: { + preserveStatus?: boolean; + preserveResumeState?: boolean; + preserveProgress?: boolean; + preserveWorktree?: boolean; + }; + /** Reset all steps to pending + currentStep 0 (store owns the impl). */ + resetSteps: () => void; +} + +// ── Field-mutation effects (applied in-lock, before commit) ─────────────────── +// +// These mirror the inline flag-off mutations in store.ts exactly. They run as +// the resolved onEnter/onExit hook bodies for the default workflow's traits. + +/** `timing` trait (in-progress): accumulate active ms on exit, stamp timing on + * entry. */ +export function applyTimingEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, fromColumn, toColumn } = ctx; + if (fromColumn === "in-progress" && toColumn !== "in-progress") { + const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt ?? ctx.movedAt); + const segmentEndMs = Date.parse(task.columnMovedAt ?? ctx.movedAt); + const segmentDeltaMs = + Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs) + ? Math.max(0, segmentEndMs - segmentStartMs) + : 0; + task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; + } + if (toColumn === "in-progress") { + task.cumulativeActiveMs ??= 0; + if (!task.firstExecutionAt) task.firstExecutionAt = task.columnMovedAt; + if (!task.executionStartedAt) task.executionStartedAt = task.columnMovedAt; + task.userPaused = undefined; + } +} + +/** Stamp `executionCompletedAt` on entry to a completion column. */ +export function applyCompletionTimingEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, toColumn } = ctx; + if (toColumn === "done" && !task.executionCompletedAt) { + task.executionCompletedAt = task.columnMovedAt; + } +} + +/** `reset-on-entry` trait (todo/triage reopen) + `abort-on-exit` userPaused + * semantics. Reproduces the legacy reopen block. */ +export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, fromColumn, toColumn, moveSource, options } = ctx; + const isReopenToTodoOrTriage = + (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") && + (toColumn === "todo" || toColumn === "triage"); + if (!isReopenToTodoOrTriage) return; + + if (!options.preserveStatus) { + task.status = undefined; + task.error = undefined; + task.pausedReason = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; + task.paused = undefined; + task.pausedByAgentId = undefined; + // abort-on-exit userPaused: only for user-source moves to todo (KTD-9). + if (moveSource === "user" && toColumn === "todo") { + task.userPaused = true; + } else { + task.userPaused = undefined; + } + + const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); + const preserveStepProgress = + options.preserveResumeState || (options.preserveProgress === true && hasNonPendingStepProgress); + + if (!options.preserveWorktree) { + task.worktree = undefined; + } + if (!options.preserveResumeState) { + task.executionStartedAt = undefined; + task.executionCompletedAt = undefined; + } else { + task.executionCompletedAt = undefined; + } + if (!preserveStepProgress) { + ctx.resetSteps(); + // Prompt-checkbox reset is a filesystem effect; the store performs it + // post-hook (it owns the task dir). Not modeled here. + } +} + +/** `merge` trait onEnter (in-review): autoMerge stamping + scheduler-state + * clearing. The queue enqueue itself is in-txn and store-owned (handoff path); + * the field effects mirror the legacy in-review block. */ +export function applyInReviewEnterEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, toColumn, settings } = ctx; + if (toColumn !== "in-review") return; + if (task.autoMerge === undefined && settings) { + task.autoMerge = settings.autoMerge; + } + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + if (task.status === "queued") { + task.status = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; +} + +/** Reopen-from-review/done field clears (branch/summary/workflowStepResults). */ +export function applyReopenFieldClears(ctx: DefaultWorkflowMoveContext): void { + const { task, fromColumn, toColumn } = ctx; + if ( + (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) || + (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) + ) { + task.workflowStepResults = undefined; + } + if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { + task.branch = undefined; + task.executionStartBranch = undefined; + task.baseCommitSha = undefined; + task.summary = undefined; + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + } +} + +/** + * Apply ALL default-workflow field-mutation move effects (the parallel of the + * legacy inline block) in the legacy order. Pure in-memory mutation of + * `ctx.task`; queue/filesystem/post-commit effects remain store-owned. + * + * This is the entry point the flag-ON store path calls. It resolves each + * trait's hook through the registry first (so a missing registration degrades to + * a no-op + audit warning, satisfying the "invokes through the registry" + * contract and the degraded-hook path); resolution warnings are collected and + * returned for the store to forward to audit. + */ +export function applyDefaultWorkflowMoveEffects( + ctx: DefaultWorkflowMoveContext, +): { warnings: TraitAuditWarning[] } { + const registry = getTraitRegistry(); + const warnings: TraitAuditWarning[] = []; + + // Resolve the hooks through the registry. The resolved impls are the closures + // registered by registerDefaultWorkflowHooks(); resolution surfaces a warning + // (and a no-op) if a registration is missing. + const toRun: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = [ + { traitId: "timing", hookKind: "onExit" }, + { traitId: "timing", hookKind: "onEnter" }, + { traitId: "reset-on-entry", hookKind: "onEnter" }, + { traitId: "abort-on-exit", hookKind: "onExit" }, + { traitId: "merge", hookKind: "onEnter" }, + ]; + for (const { traitId, hookKind } of toRun) { + const { impl, warning } = registry.resolveTraitHook(traitId, hookKind); + if (warning) warnings.push(warning); + if (impl) impl(ctx); + } + + return { warnings }; +} + +// ── Registration into the trait registry (DI seam) ─────────────────────────── + +let registered = false; + +/** + * Register the default-workflow hook implementations into the shared trait + * registry. Idempotent. Called at store init (the store is the engine-adjacent + * owner of the move lifecycle). Each registration is a thin adapter that runs + * the corresponding field-effect function over the move context. + * + * The legacy effects map onto traits as: + * timing.onExit / timing.onEnter → applyTimingEffects + completion stamp + * reset-on-entry.onEnter → applyResetOnEntryEffects + reopen clears + * abort-on-exit.onExit → (userPaused handled in reset-on-entry; + * session abort is an engine effect U6/U7) + * merge.onEnter → applyInReviewEnterEffects + */ +export function registerDefaultWorkflowHooks(): void { + if (registered) return; + const registry = getTraitRegistry(); + + const cast = (fn: (ctx: DefaultWorkflowMoveContext) => void) => + ((...args: unknown[]) => fn(args[0] as DefaultWorkflowMoveContext)) as ( + ...args: unknown[] + ) => unknown; + + registry.registerTraitHookImpl( + "timing", + "onExit", + cast((ctx) => { + applyTimingEffects(ctx); + }), + ); + registry.registerTraitHookImpl( + "timing", + "onEnter", + cast((ctx) => { + applyCompletionTimingEffects(ctx); + }), + ); + registry.registerTraitHookImpl( + "reset-on-entry", + "onEnter", + cast((ctx) => { + applyResetOnEntryEffects(ctx); + applyReopenFieldClears(ctx); + }), + ); + registry.registerTraitHookImpl( + "abort-on-exit", + "onExit", + cast(() => { + // userPaused is set in applyResetOnEntryEffects (the legacy ordering keeps + // it with the reopen block). Session-abort wiring is an engine effect that + // lands with U6/U7; here it is intentionally a no-op so the resolved hook + // exists (not a missing-impl warning) while carrying no field mutation. + }), + ); + registry.registerTraitHookImpl( + "merge", + "onEnter", + cast((ctx) => { + applyInReviewEnterEffects(ctx); + }), + ); + + registered = true; +} + +/** Test-only: allow re-registration after a registry reset. */ +export function __resetDefaultWorkflowHooksForTests(): void { + registered = false; +} diff --git a/packages/core/src/duplicate-detection.ts b/packages/core/src/duplicate-detection.ts index 9dd541b368..e4abd70874 100644 --- a/packages/core/src/duplicate-detection.ts +++ b/packages/core/src/duplicate-detection.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto"; -import type { Column } from "./types.js"; +import type { Column, ColumnId } from "./types.js"; export interface DuplicateMatch { id: string; title: string; description: string; - column: Column; + column: ColumnId; score: number; } @@ -19,7 +19,7 @@ export interface DuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; } export interface ContentFingerprintInput { @@ -138,7 +138,7 @@ export function findDuplicateMatches( const threshold = opts?.threshold ?? DEFAULT_THRESHOLD; const limit = opts?.limit ?? DEFAULT_LIMIT; - const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS); + const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS); const sourceText = `${input.title ?? ""} ${description}`.trim(); const sourceTokens = new Set(tokenize(sourceText)); const sourceTitle = input.title ?? ""; diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts index e7a8994022..59ed39d583 100644 --- a/packages/core/src/duplicate-intake.ts +++ b/packages/core/src/duplicate-intake.ts @@ -1,5 +1,5 @@ import { findDuplicateMatches } from "./duplicate-detection.js"; -import type { Column } from "./types.js"; +import type { ColumnId } from "./types.js"; import type { TaskStore } from "./store.js"; export interface SameAgentDuplicateInput { @@ -17,7 +17,7 @@ export interface SameAgentDuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; createdAt: number; sourceAgentId: string | null; sourceParentTaskId?: string | null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc731acb8f..a8d0d77420 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -49,14 +49,173 @@ export { parseWorkflowIr, serializeWorkflowIr, WorkflowIrError, + DEFAULT_WORKFLOW_COLUMN_IDS, } from "./workflow-ir.js"; export type { WorkflowIr, + WorkflowIrV1, + WorkflowIrV2, WorkflowIrNode, WorkflowIrEdge, WorkflowIrNodeKind, + WorkflowIrColumn, + WorkflowIrColumnTrait, + WorkflowHoldRelease, + WorkflowJoinMode, + WorkflowJoinBranchFailure, + // Step-inversion (KTD-3/12/13): foreach / artifacts / custom-field IR types. + WorkflowForeachConfig, + WorkflowIrArtifact, + WorkflowFieldDefinition, + WorkflowFieldType, + WorkflowFieldOption, + WorkflowFieldRender, } from "./workflow-ir-types.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; + +// ── Trait model (U2) ───────────────────────────────────────────────── +export type { + TraitDefinition, + TraitFlags, + TraitConfigSchema, + TraitConfigField, + TraitHookDescriptors, + TraitHookKind, + TraitHookImpl, + RestrictedTraitFlag, +} from "./trait-types.js"; +export { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js"; +export { + TraitRegistry, + TraitRegistrationError, + getTraitRegistry, + getTrait, + listTraits, + resolveColumnFlags, + validateColumnTraits, + assertColumnTraitsValid, + ColumnTraitValidationError, + registerTraitHookImpl, + __resetTraitRegistryForTests, +} from "./trait-registry.js"; +export type { + TraitRegistrationReason, + TraitViolation, + TraitViolationCode, + TraitViolationSeverity, + TraitAuditWarning, +} from "./trait-registry.js"; +export { + BUILTIN_TRAIT_IDS, + BUILTIN_TRAIT_DEFINITIONS, + registerBuiltinTraits, +} from "./builtin-traits.js"; +export type { BuiltinTraitId } from "./builtin-traits.js"; +// Step-inversion U12 (KTD-12): step-parser registry + built-ins. +export { + StepParserRegistry, + StepParserRegistrationError, + getStepParserRegistry, + registerStepParser, + getStepParser, + listStepParsers, + unregisterStepParser, + registerBuiltinStepParsers, + parseStepHeadings, + parseJsonSteps, + __resetStepParserRegistryForTests, +} from "./step-parsers.js"; +export type { + StepParser, + StepParseResult, + ParsedStep, + StepParserRegistrationReason, +} from "./step-parsers.js"; +export { + registerDefaultWorkflowHooks, + __resetDefaultWorkflowHooksForTests, +} from "./default-workflow-hooks.js"; +// ── Typed transition contract + crash-safe marker (U3) ─────────────── +export type { + TransitionRejection, + TransitionRejectionCode, + TransitionResult, + TransitionPending, +} from "./transition-types.js"; +export { + TRANSITION_REJECTION_CODES, + makeTransitionRejection, + makeTransitionPending, + transitionOk, + transitionRejected, + serializeTransitionRejection, + deserializeTransitionRejection, + serializeTransitionPending, + deserializeTransitionPending, +} from "./transition-types.js"; +export type { + TransitionPendingDbHandle, + ReconcileHooksResult, +} from "./transition-pending.js"; +// ── U4: workflow-resolved transition adjacency + flag accessor ─────────────── +export { + resolveColumnAdjacency, + resolveAllowedColumns, + workflowHasColumn, +} from "./workflow-transitions.js"; +export type { ColumnAdjacency } from "./workflow-transitions.js"; +export { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; +// ── U8: pre-evaluated plugin gate verdicts (KTD-2) ─────────────────────────── +export { + findWorkflowColumn, + resolveColumnPluginGates, +} from "./plugin-gate-verdict.js"; +export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js"; +// ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── +export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js"; +export type { ColumnCapacity } from "./workflow-capacity.js"; +// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ─────────── +export { + OccupiedColumnsError, + InvalidRehomeTargetError, + IncompatibleFieldChangeError, + resolveEntryColumnId, + resolveSwitchReconciliation, + computeRemovedOccupiedColumns, + computeIncompatibleFieldChanges, + assertRehomeTargetValid, + setReconciliationAbort, + runReconciliationAbort, + __resetReconciliationAbortForTests, +} from "./workflow-reconciliation.js"; +export type { + SwitchReconciliation, + ColumnOccupancy, + IncompatibleFieldChange, + ReconciliationAbort, + ReconciliationAbortContext, +} from "./workflow-reconciliation.js"; +export { + validateCustomFieldPatch, + applyFieldDefaults, + reconcileFieldsOnWorkflowChange, + makeCustomFieldRejection, + CustomFieldRejectionError, + CUSTOM_FIELD_REJECTION_CODES, +} from "./task-fields.js"; +export type { + CustomFieldRejection, + CustomFieldRejectionCode, + CustomFieldPatchResult, + FieldReconciliation, +} from "./task-fields.js"; +export { + readTransitionPending, + writeTransitionPending, + clearTransitionPending, + reconcileHooksRemaining, +} from "./transition-pending.js"; export type { WorkflowDefinition, WorkflowDefinitionInput, @@ -74,6 +233,11 @@ export { getBuiltinWorkflow, isBuiltinWorkflowId, } from "./builtin-workflows.js"; +export { + resolveWorkflowIrForTask, + resolveWorkflowIrById, + type WorkflowIrResolverStore, +} from "./workflow-ir-resolver.js"; // ── Engine wiring (set by @fusion/engine at module load) ──────────── export { @@ -186,6 +350,7 @@ export { MergeQueueLeaseOwnershipError, InvalidMergeQueueLeaseDurationError, HandoffInvariantViolationError, + TransitionRejectionError, } from "./store.js"; export { STOPWORDS, @@ -570,6 +735,9 @@ export type { PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, + PluginTraitHookDescriptor, + PluginTraitFlags, PluginPromptSurface, PluginPromptContribution, PluginPromptContributions, @@ -584,10 +752,18 @@ export type { PluginState, PluginInstallation, } from "./plugin-types.js"; -export { validatePluginManifest, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition } from "./plugin-types.js"; +export { + validatePluginManifest, + validatePluginTraitContribution, + PLUGIN_TRAIT_RESTRICTED_FLAGS, + PLUGIN_TRAIT_ALLOWED_HOOK_POINTS, + PLUGIN_TRAIT_SCHEMA_VERSION, + normalizePluginUiContributionSurface, + normalizePluginUiContributionDefinition, +} from "./plugin-types.js"; export { PluginStore } from "./plugin-store.js"; export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js"; -export { PluginLoader } from "./plugin-loader.js"; +export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js"; export { scanPluginSecurity } from "./plugin-security-scan.js"; export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js"; export type { @@ -1143,6 +1319,10 @@ export { deriveStageTransitions, buildWorkflowObservationFromTask, buildWorkflowObservation, + checkTransitionParity, + countDualAcceptDisagreements, + computeWorkflowColumnsGraduationReport, + DUAL_ACCEPT_PARITY_MUTATIONS, } from "./workflow-parity.js"; export type { WorkflowAuditObservation, @@ -1157,6 +1337,11 @@ export type { WorkflowObservationBuildOptions, WorkflowObservationParts, WorkflowParitySummary, + TransitionParityDiff, + TransitionParityReport, + DualAcceptDisagreementReport, + WorkflowColumnsGraduationReport, + GraduationReportInputs, } from "./workflow-parity.js"; export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js"; export type { ResolvedResearchSettings } from "./research-settings.js"; diff --git a/packages/core/src/near-duplicate.ts b/packages/core/src/near-duplicate.ts index 992ff1c99c..c9480d4826 100644 --- a/packages/core/src/near-duplicate.ts +++ b/packages/core/src/near-duplicate.ts @@ -1,5 +1,5 @@ import { STOPWORDS, tokenize } from "./duplicate-detection.js"; -import type { Column } from "./types.js"; +import type { ColumnId } from "./types.js"; const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_LIMIT = 5; @@ -34,7 +34,7 @@ export interface NearDuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; fileScope?: string[]; createdAt?: number; } diff --git a/packages/core/src/plugin-gate-verdict.ts b/packages/core/src/plugin-gate-verdict.ts new file mode 100644 index 0000000000..ad51217afb --- /dev/null +++ b/packages/core/src/plugin-gate-verdict.ts @@ -0,0 +1,83 @@ +/** + * Pre-evaluated plugin gate verdicts (U8, KTD-2). + * + * Per KTD-2 a plugin gate is evaluated *before* the move is attempted, OUTSIDE + * the task lock (via the prompt-session/script/verdict machinery). The verdict + * is recorded and then re-checked cheaply IN-LOCK at move time — this removes + * any path where plugin code can block or wedge the task lock. + * + * The engine (PluginRunner trait adapter) evaluates the gate and records the + * verdict through `TaskStore.recordPluginGateVerdict`; the flag-ON guard site in + * `moveTaskInternal` consumes it through `consumePluginGateVerdicts` and rejects + * the move when a blocking gate has no recorded `allow` verdict. + * + * U8 keeps the storage minimal and surgical (an in-memory map on the store) per + * the unit's "define it here minimally" note. The shape below is the seam a + * later unit can back with SQLite without changing call sites. + */ + +import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; +import type { TraitDefinition } from "./trait-types.js"; + +/** A recorded gate verdict for a (task, targetColumn, trait). */ +export interface PluginGateVerdict { + /** The registry-facing trait id (e.g. `plugin::`). */ + traitId: string; + /** Whether the gate verdict allows the move into the target column. */ + allow: boolean; + /** `blocking` fails closed on a non-allow verdict; `advisory` records+allows. */ + gateMode: "blocking" | "advisory"; + /** Human-readable detail surfaced in the rejection / audit. */ + detail?: string; + /** When the verdict was recorded (epoch ms). */ + recordedAt: number; +} + +/** A plugin gate trait found on a column (id + its declared gate mode). */ +export interface ColumnPluginGate { + /** The column trait's registry id. */ + traitId: string; + /** Gate mode from the column trait's `config.gateMode` (defaults to blocking). */ + gateMode: "blocking" | "advisory"; +} + +/** Resolve a workflow column by id from a (v2) IR, or undefined. */ +export function findWorkflowColumn( + ir: WorkflowIr, + columnId: string, +): WorkflowIrColumn | undefined { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return undefined; + return v2.columns.find((c) => c.id === columnId); +} + +/** + * Identify the PLUGIN gate traits on a target column. A trait qualifies when: + * - its registry id is namespaced (`plugin:...`) — built-in gate traits are + * handled by the built-in gate path, not this plugin-facing surface; AND + * - it actually declares a gate (a `gate` hook descriptor or the `gate` flag), + * resolved via `lookupTrait`. A plugin trait with only onEnter/onExit/etc. + * is NOT a gate and must not demand a verdict. + * + * The gate mode is read from the column trait's `config.gateMode` (defaults to + * blocking, matching the built-in gate's fail-closed posture). + */ +export function resolveColumnPluginGates( + column: WorkflowIrColumn | undefined, + lookupTrait?: (traitId: string) => TraitDefinition | undefined, +): ColumnPluginGate[] { + if (!column) return []; + const gates: ColumnPluginGate[] = []; + for (const ct of column.traits) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = lookupTrait?.(ct.trait); + // When a lookup is supplied, require the trait to actually declare a gate. + // Without a lookup (no registry access) we fall back to treating any plugin + // trait as a potential gate — the conservative fail-closed default. + if (lookupTrait && !(def?.hooks?.gate || def?.flags?.gate)) continue; + const cfgMode = ct.config?.gateMode; + const gateMode = cfgMode === "advisory" ? "advisory" : "blocking"; + gates.push({ traitId: ct.trait, gateMode }); + } + return gates; +} diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 4d06bb6532..098635f848 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -9,7 +9,8 @@ * - Error isolation (plugin crashes don't crash the loader) */ -import { basename, dirname, extname, isAbsolute, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; import { stat } from "node:fs/promises"; import { copyFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; @@ -32,6 +33,7 @@ import type { PluginInstallation, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, PluginPromptContribution, PluginPromptContributions, PluginSetupManifest, @@ -47,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js"; const MINIMUM_FUSION_VERSION = "0.1.0"; let moduleImportVersion = 0; +/** + * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM + * does not allow directory imports, so the registered plugin path must be the + * explicit file the loader will dynamic-import. Preference order: + * 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) + * 2. ./dist/index.js (legacy prebuilt fallback) + * 3. ./src/index.ts (workspace/dev fallback when no bundle exists) + * + * Returns null when the directory exists but none of the loadable entry files + * are present. Callers must treat that as a missing/unloadable plugin rather + * than persisting a directory path that Node cannot import. + * + * Keep in sync with resolvePluginEntryPath in the CLI's + * bundled-plugin-install.ts, which keeps a local copy so its fs mocks work. + */ +export function resolvePluginEntryPath(pluginDir: string): string | null { + const candidates = [ + join(pluginDir, "bundled.js"), + join(pluginDir, "dist", "index.js"), + join(pluginDir, "src", "index.ts"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + export interface PluginLoaderOptions { /** Plugin store for persistence */ pluginStore: PluginStore; @@ -1036,6 +1067,21 @@ export class PluginLoader extends EventEmitter<{ return steps; } + /** + * Get all trait contributions from loaded plugins (U8). + */ + getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> { + const traits: Array<{ pluginId: string; trait: PluginTraitContribution }> = []; + for (const [pluginId, plugin] of this.plugins) { + if (plugin.traits) { + for (const trait of plugin.traits) { + traits.push({ pluginId, trait }); + } + } + } + return traits; + } + /** * Get all workflow step templates derived from loaded plugin contributions. */ diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 2af8d98957..4158bb57b1 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -49,6 +49,8 @@ export interface PluginManifest { skills?: Array<{ skillId: string; name: string }>; /** Optional workflow step metadata used for discovery UIs. */ workflowSteps?: Array<{ stepId: string; name: string }>; + /** Optional trait metadata used for discovery UIs (U8). */ + traits?: Array<{ traitId: string; name: string }>; /** Prompt surfaces this plugin contributes to. */ promptSurfaces?: PluginPromptSurface[]; /** Setup metadata for plugin-managed binaries/runtimes. */ @@ -692,6 +694,212 @@ export interface PluginWorkflowStepContribution { modelId?: string; } +/** + * Plugin-contributed trait (U8, R6/R22, KTD-7). + * + * Plugins declare traits in their manifest the way they declare workflow steps. + * A trait carries declarative flags + an optional config schema + async-only + * hook descriptors. The contract is a VERSIONED hook-descriptor schema + * (`schemaVersion`) so the built-in trait vocabulary can grow additively (new + * flags, hook points, config fields) without breaking published plugin traits. + * + * Restricted (built-in-only) capabilities a plugin trait may NOT declare (R22, + * KTD-2/KTD-7), rejected at validation: + * - the `complete` / `archived` flags (silently satisfying dependencies / + * hiding cards is a scheduling-poison surface); + * - a sync `guard` hook (sync guards run in-lock and must be fast/pure — a + * plugin hook there could wedge the task lock). + * + * Plugin traits get ASYNC hook points only: `gate`, `onEnter`, `onExit`, + * `releaseCondition`. Each hook descriptor mirrors PluginWorkflowStepContribution's + * declarative shape (mode + prompt/scriptName) so the existing prompt-session / + * script / verdict machinery executes them; gates additionally carry `gateMode`. + */ +export interface PluginTraitHookDescriptor { + /** How the hook runs: a model prompt or a named project script. */ + mode: "prompt" | "script"; + /** Prompt text used when `mode === "prompt"`. */ + prompt?: string; + /** Named project script used when `mode === "script"`. */ + scriptName?: string; + /** + * Gate semantics (gate hook only): `blocking` fails closed (a non-pass + * verdict rejects the move); `advisory` records the verdict and allows the + * move. Ignored for non-gate hooks. Defaults to `blocking` for gate hooks. + */ + gateMode?: "blocking" | "advisory"; +} + +/** + * The declarative flag subset a plugin trait may declare. Restricted flags + * (`complete`, `archived`) are intentionally absent from this type AND rejected + * at validation — declaring them is a contribution error, not silently ignored. + */ +export interface PluginTraitFlags { + countsTowardWip?: boolean; + hiddenFromBoard?: boolean; + abortOnExit?: boolean; + humanReview?: boolean; + intake?: boolean; + hold?: boolean; + mergeOrchestration?: boolean; + mergeBlocker?: boolean; + resetOnEntry?: boolean; + timing?: boolean; + stallDetection?: boolean; + notify?: boolean; + gate?: boolean; +} + +export interface PluginTraitContribution { + /** Unique trait identifier within the plugin namespace (kebab-case). The + * registry-facing id is namespaced as `plugin::`. */ + traitId: string; + /** Human-readable trait name. */ + name: string; + /** Short description for UI. */ + description?: string; + /** Versioned hook-descriptor schema. Currently `1`. Required so the + * vocabulary can extend additively without breaking published traits. */ + schemaVersion: 1; + /** Declarative flags (restricted flags rejected at validation, R22). */ + flags?: PluginTraitFlags; + /** Optional declarative config schema fields (shape mirrors TraitConfigField). */ + configSchema?: { + fields: Array<{ + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + enumValues?: readonly string[]; + description?: string; + }>; + }; + /** Async-only hook descriptors (R22). A `guard` key is NOT permitted and is + * rejected at validation. */ + hooks?: { + gate?: PluginTraitHookDescriptor; + onEnter?: PluginTraitHookDescriptor; + onExit?: PluginTraitHookDescriptor; + releaseCondition?: PluginTraitHookDescriptor; + }; +} + +/** The restricted flag keys a plugin trait may not declare (R22, KTD-7). */ +export const PLUGIN_TRAIT_RESTRICTED_FLAGS = ["complete", "archived"] as const; + +/** The async-only hook points a plugin trait may declare (R22). The sync + * `guard` hook point is built-in-only and rejected at validation. */ +export const PLUGIN_TRAIT_ALLOWED_HOOK_POINTS = [ + "gate", + "onEnter", + "onExit", + "releaseCondition", +] as const; + +/** The current plugin trait hook-descriptor schema version. */ +export const PLUGIN_TRAIT_SCHEMA_VERSION = 1 as const; + +/** + * Validate one plugin trait contribution. Returns a list of human-readable + * error strings (empty = valid). Mirrors the validation posture of + * `validatePluginManifest`'s `workflowSteps` block: structural checks plus the + * R22 restricted-capability checks (sync `guard` key, restricted flags) and the + * required versioned `schemaVersion`. + */ +export function validatePluginTraitContribution( + trait: unknown, + index = 0, +): string[] { + const errors: string[] = []; + const prefix = `traits[${index}]`; + if (!trait || typeof trait !== "object" || Array.isArray(trait)) { + return [`${prefix} must be an object`]; + } + const t = trait as Record; + + if (!t.traitId || typeof t.traitId !== "string" || t.traitId.trim() === "") { + errors.push(`${prefix}.traitId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(t.traitId)) { + errors.push( + `${prefix}.traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`, + ); + } + + if (!t.name || typeof t.name !== "string" || t.name.trim() === "") { + errors.push(`${prefix}.name is required and must be a non-empty string`); + } + + // schemaVersion is required and must be the supported version (versioned + // hook-descriptor extension contract). + if (t.schemaVersion === undefined) { + errors.push(`${prefix}.schemaVersion is required (versioned hook-descriptor schema)`); + } else if (t.schemaVersion !== PLUGIN_TRAIT_SCHEMA_VERSION) { + errors.push( + `${prefix}.schemaVersion must be ${PLUGIN_TRAIT_SCHEMA_VERSION}; got ${String(t.schemaVersion)}`, + ); + } + + // Restricted flags (R22): a plugin trait must not declare complete/archived. + if (t.flags !== undefined) { + if (typeof t.flags !== "object" || t.flags === null || Array.isArray(t.flags)) { + errors.push(`${prefix}.flags must be an object`); + } else { + const flags = t.flags as Record; + for (const restricted of PLUGIN_TRAIT_RESTRICTED_FLAGS) { + if (flags[restricted]) { + errors.push( + `${prefix}.flags.${restricted} is a restricted (built-in-only) flag and may not be declared by a plugin trait`, + ); + } + } + } + } + + // Hooks: async-only. A sync `guard` key is rejected (R22, KTD-2). + if (t.hooks !== undefined) { + if (typeof t.hooks !== "object" || t.hooks === null || Array.isArray(t.hooks)) { + errors.push(`${prefix}.hooks must be an object`); + } else { + const hooks = t.hooks as Record; + if ("guard" in hooks) { + errors.push( + `${prefix}.hooks.guard is a sync (built-in-only) hook point and may not be declared by a plugin trait`, + ); + } + for (const [hookKind, descriptor] of Object.entries(hooks)) { + if (hookKind === "guard") continue; // already reported + if (!(PLUGIN_TRAIT_ALLOWED_HOOK_POINTS as readonly string[]).includes(hookKind)) { + errors.push( + `${prefix}.hooks.${hookKind} is not a recognized async hook point (allowed: ${PLUGIN_TRAIT_ALLOWED_HOOK_POINTS.join(", ")})`, + ); + continue; + } + if (!descriptor || typeof descriptor !== "object") { + errors.push(`${prefix}.hooks.${hookKind} must be an object`); + continue; + } + const d = descriptor as Record; + if (d.mode !== "prompt" && d.mode !== "script") { + errors.push(`${prefix}.hooks.${hookKind}.mode must be one of: prompt, script`); + } + if (d.mode === "script" && (typeof d.scriptName !== "string" || d.scriptName.trim() === "")) { + errors.push(`${prefix}.hooks.${hookKind}.scriptName is required when mode is "script"`); + } + if ( + hookKind === "gate" && + d.gateMode !== undefined && + d.gateMode !== "blocking" && + d.gateMode !== "advisory" + ) { + errors.push(`${prefix}.hooks.gate.gateMode must be one of: blocking, advisory`); + } + } + } + } + + return errors; +} + /** * Prompt injection surfaces for plugin-contributed instructions. * - executor-system: Appended to executor agent system prompt @@ -829,6 +1037,8 @@ export interface FusionPlugin { skills?: PluginSkillContribution[]; /** Plugin-contributed workflow step templates. */ workflowSteps?: PluginWorkflowStepContribution[]; + /** Plugin-contributed column traits (U8). */ + traits?: PluginTraitContribution[]; /** Plugin-contributed prompt injections. */ promptContributions?: PluginPromptContributions; /** Plugin-managed setup metadata and lifecycle hooks. */ @@ -1024,6 +1234,38 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err } } + // Optional: plugin trait contributions (U8). Full contribution shapes (with + // hooks/flags) validate via validatePluginTraitContribution; the discovery + // metadata form (`{ traitId, name }`) validates structurally here. + if (m.traits !== undefined) { + if (!Array.isArray(m.traits)) { + errors.push("traits must be an array"); + } else { + for (const [index, trait] of m.traits.entries()) { + if (!trait || typeof trait !== "object") { + errors.push(`traits[${index}] must be an object`); + continue; + } + const traitMeta = trait as Record; + // A full contribution carries schemaVersion/flags/hooks — validate it + // fully. The discovery-metadata form (just traitId + name) is validated + // structurally. + if (traitMeta.schemaVersion !== undefined || traitMeta.hooks !== undefined || traitMeta.flags !== undefined) { + errors.push(...validatePluginTraitContribution(traitMeta, index)); + continue; + } + if (!traitMeta.traitId || typeof traitMeta.traitId !== "string" || traitMeta.traitId.trim() === "") { + errors.push(`traits[${index}].traitId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(traitMeta.traitId)) { + errors.push(`traits[${index}].traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`); + } + if (!traitMeta.name || typeof traitMeta.name !== "string" || traitMeta.name.trim() === "") { + errors.push(`traits[${index}].name is required and must be a non-empty string`); + } + } + } + } + // Optional: prompt surface metadata if (m.promptSurfaces !== undefined) { if (!Array.isArray(m.promptSurfaces)) { diff --git a/packages/core/src/step-parsers.ts b/packages/core/src/step-parsers.ts new file mode 100644 index 0000000000..4962c3b63d --- /dev/null +++ b/packages/core/src/step-parsers.ts @@ -0,0 +1,372 @@ +/** + * Step-parser registry (U12, KTD-12). + * + * Step parsing becomes a graph-native node (`parse-steps`): a registry resolves + * a parser id to an implementation that reads an artifact's content and yields a + * canonical step list. Built-ins: + * - `step-headings` — the extracted `parseStepsFromPrompt` logic (the + * `### Step N:` regex + `(depends: …)` annotation from U1); legacy callers + * in `store.ts` delegate to this exact function (byte-identical parity). + * - `json-steps` — a structured `[{ name, depends? }]` JSON document for + * workflows that plan in JSON. + * + * The registry mirrors the trait-registry posture: built-ins are protected from + * override, and plugins register under namespaced ids + * (`plugin::`). This module is engine-free and must NOT + * import `store.ts` (store imports the extracted parser from here). + * + * Parsers may throw on malformed input; callers (the engine's parse-steps + * handler) map a throw to a routable `outcome:parse-error`. + */ + +import type { TaskStep } from "./types.js"; + +// ── Parser contract ────────────────────────────────────────────────────────── + +/** A parsed step as produced by a parser. `dependsOn` is 0-indexed (same + * convention as the headings `(depends: …)` annotation). */ +export interface ParsedStep { + name: string; + dependsOn?: number[]; +} + +/** The result of running a step parser over an artifact's content. */ +export interface StepParseResult { + steps: ParsedStep[]; +} + +/** A step parser. `parse` may throw on malformed input; the caller maps a throw + * to a routable parse-error outcome. */ +export interface StepParser { + id: string; + parse(content: string): StepParseResult; +} + +// ── Registration error ────────────────────────────────────────────────────── + +/** Named reason codes for a rejected step-parser registration. */ +export type StepParserRegistrationReason = + | "duplicate-id" + | "builtin-namespace-protected" + | "invalid-id" + | "invalid-definition"; + +export class StepParserRegistrationError extends Error { + readonly reason: StepParserRegistrationReason; + readonly parserId: string; + constructor(reason: StepParserRegistrationReason, parserId: string, message: string) { + super(message); + this.name = "StepParserRegistrationError"; + this.reason = reason; + this.parserId = parserId; + } +} + +// ── The registry ──────────────────────────────────────────────────────────── + +interface RegisteredParser { + parser: StepParser; + builtin: boolean; +} + +/** Validate a plugin-namespaced parser id: `plugin::` with + * each segment a non-empty `[a-z0-9-]+` token. */ +function isValidPluginParserId(id: string): boolean { + const parts = id.split(":"); + if (parts.length !== 3) return false; + if (parts[0] !== "plugin") return false; + const seg = /^[a-z0-9-]+$/; + return seg.test(parts[1]) && seg.test(parts[2]); +} + +export class StepParserRegistry { + private readonly parsers = new Map(); + + /** Register a parser. Built-in ids cannot be overridden by non-builtins; a + * non-builtin must use a `plugin::` id. */ + register(parser: StepParser, opts?: { builtin?: boolean }): void { + const builtin = opts?.builtin ?? false; + if (!parser || typeof parser.id !== "string" || parser.id === "") { + throw new StepParserRegistrationError( + "invalid-definition", + String(parser?.id), + "Step parser must have a non-empty string id", + ); + } + if (typeof parser.parse !== "function") { + throw new StepParserRegistrationError( + "invalid-definition", + parser.id, + `Step parser '${parser.id}' must have a parse() function`, + ); + } + + // Existing-id checks first (built-in protection, then duplicate) so a + // non-builtin trying to overwrite a built-in surfaces the protection reason + // rather than the id-shape reason. + const existing = this.parsers.get(parser.id); + if (existing) { + if (!builtin && existing.builtin) { + throw new StepParserRegistrationError( + "builtin-namespace-protected", + parser.id, + `Step parser id '${parser.id}' is a built-in parser and cannot be overridden by a non-builtin registration`, + ); + } + throw new StepParserRegistrationError( + "duplicate-id", + parser.id, + `Step parser id '${parser.id}' is already registered`, + ); + } + + if (!builtin && !isValidPluginParserId(parser.id)) { + throw new StepParserRegistrationError( + "invalid-id", + parser.id, + `Non-builtin step parser '${parser.id}' must use a namespaced id of the form 'plugin::'`, + ); + } + + this.parsers.set(parser.id, { parser, builtin }); + } + + getParser(id: string): StepParser | undefined { + return this.parsers.get(id)?.parser; + } + + has(id: string): boolean { + return this.parsers.has(id); + } + + listParsers(): StepParser[] { + return [...this.parsers.values()].map((r) => r.parser); + } + + /** Remove a parser. Built-ins are never removed (callers should only pass + * plugin-namespaced ids — e.g. for plugin teardown). Returns true if a + * non-builtin parser was present and removed. */ + unregister(id: string): boolean { + const existing = this.parsers.get(id); + if (!existing || existing.builtin) return false; + return this.parsers.delete(id); + } +} + +// ── Built-in: step-headings ─────────────────────────────────────────────────── + +/** + * Parse `### Step N:` headings into the task step list (step-inversion U1). + * + * Backward compatibility is exact: an UNannotated heading parses byte-identically + * to the legacy regex `^###\s+Step\s+\d+[^:]*:\s*(.+)$` (name = text after the + * first colon, trimmed). + * + * The annotation `### Step N (depends: 1,2): Title` is parsed explicitly (the + * legacy regex breaks on the colon inside `depends:`): depends values are + * 1-indexed step numbers in the document and are stored as 0-indexed indices on + * `dependsOn` (deduped, sorted, dropping values <= 0). + * + * Malformed `(depends: …)` annotations fall back deterministically: the heading + * is treated as `### Step N:` with the name starting after the FIRST colon + * following the closing paren (if present), else after the first colon — and no + * `dependsOn` is recorded. + */ +export function parseStepHeadings(content: string): TaskStep[] { + const steps: TaskStep[] = []; + // Legacy matcher — UNCHANGED from the original implementation, so unannotated + // headings (and every legacy edge case, including `[^:]*` spanning newlines) + // parse byte-identically. The full match (`m[0]`) is re-inspected only to layer + // the `(depends: …)` annotation on top. + const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm; + // Well-formed annotation form: `### Step N (depends: …): name`. + const annotatedRegex = /^###\s+Step\s+\d+\s*\(depends:\s*([^)]*)\)\s*:\s*([^\n]+)$/; + + let match: RegExpExecArray | null; + while ((match = stepRegex.exec(content)) !== null) { + const full = match[0]; + + // No annotation present → byte-identical legacy behavior. + if (!full.includes("(depends:")) { + steps.push({ name: match[1].trim(), status: "pending" }); + continue; + } + + // 1) Well-formed depends annotation. + const annotated = annotatedRegex.exec(full); + if (annotated) { + const parsed = parseDependsList(annotated[1]); + const name = annotated[2].trim(); + if (parsed !== null) { + if (parsed.length > 0) steps.push({ name, status: "pending", dependsOn: parsed }); + else steps.push({ name, status: "pending" }); + continue; + } + } + + // 2) Annotation present but unparseable (bad values or no closing paren): + // deterministic fallback — name starts after the FIRST colon following the + // closing paren if present, else after the first colon. Operate on the + // first line of the match only (the heading line itself). + const line = full.split("\n")[0]; + const parenIdx = line.indexOf(")"); + const colonAfterParen = parenIdx >= 0 ? line.indexOf(":", parenIdx) : -1; + const colonIdx = colonAfterParen >= 0 ? colonAfterParen : line.indexOf(":"); + if (colonIdx >= 0) { + const fallbackName = line.slice(colonIdx + 1).trim(); + if (fallbackName) steps.push({ name: fallbackName, status: "pending" }); + } + } + return steps; +} + +/** Parse a `depends:` value list (1-indexed step numbers) into 0-indexed, + * deduped, sorted indices. Returns null if any token is not a positive integer. */ +function parseDependsList(raw: string): number[] | null { + const trimmed = raw.trim(); + if (trimmed === "") return []; + const tokens = trimmed.split(",").map((t) => t.trim()); + const out = new Set(); + for (const token of tokens) { + if (!/^\d+$/.test(token)) return null; + const n = Number(token); + if (!Number.isInteger(n) || n < 1) return null; + out.add(n - 1); + } + return [...out].sort((a, b) => a - b); +} + +// ── Built-in: json-steps ────────────────────────────────────────────────────── + +/** + * Parse a JSON document: an array of `{ name: string, depends?: number[] }`. + * `depends` values are 1-indexed step numbers in the document (same convention + * as the headings annotation), converted to 0-indexed `dependsOn` (deduped, + * sorted). Throws a descriptive error on any malformed input (not JSON, not an + * array, missing/blank name, bad depends). + */ +export function parseJsonSteps(content: string): StepParseResult { + let doc: unknown; + try { + doc = JSON.parse(content); + } catch (err) { + throw new Error( + `json-steps: content is not valid JSON: ${(err as Error).message}`, + ); + } + + if (!Array.isArray(doc)) { + throw new Error("json-steps: document must be a JSON array of step objects"); + } + + const steps: ParsedStep[] = []; + doc.forEach((entry, i) => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw new Error(`json-steps: step at index ${i} must be an object`); + } + const obj = entry as Record; + const name = obj.name; + if (typeof name !== "string" || name.trim() === "") { + throw new Error( + `json-steps: step at index ${i} must have a non-empty string 'name'`, + ); + } + + const step: ParsedStep = { name: name.trim() }; + + if (obj.depends !== undefined) { + if (!Array.isArray(obj.depends)) { + throw new Error( + `json-steps: step at index ${i} 'depends' must be an array of positive integers`, + ); + } + const out = new Set(); + for (const raw of obj.depends) { + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) { + throw new Error( + `json-steps: step at index ${i} 'depends' must contain only positive integers (1-indexed step numbers); got ${JSON.stringify(raw)}`, + ); + } + out.add(raw - 1); + } + const dependsOn = [...out].sort((a, b) => a - b); + if (dependsOn.length > 0) step.dependsOn = dependsOn; + } + + steps.push(step); + }); + + return { steps }; +} + +// ── Built-in parser definitions ─────────────────────────────────────────────── + +const BUILTIN_STEP_PARSERS: StepParser[] = [ + { + id: "step-headings", + parse(content: string): StepParseResult { + // The headings parser yields TaskStep[]; map to the parser contract + // (dropping the `status` field, which the caller re-applies). + const steps = parseStepHeadings(content).map((s) => { + const out: ParsedStep = { name: s.name }; + if (s.dependsOn) out.dependsOn = s.dependsOn; + return out; + }); + return { steps }; + }, + }, + { + id: "json-steps", + parse: parseJsonSteps, + }, +]; + +/** Register the built-in step parsers into the given registry (defaults to the + * shared registry). Idempotent via `has`. */ +export function registerBuiltinStepParsers( + registry: StepParserRegistry = getStepParserRegistry(), +): void { + for (const parser of BUILTIN_STEP_PARSERS) { + if (registry.has(parser.id)) continue; + registry.register(parser, { builtin: true }); + } +} + +// ── Module-level default registry ─────────────────────────────────────────── + +let defaultRegistry: StepParserRegistry | undefined; + +export function getStepParserRegistry(): StepParserRegistry { + if (!defaultRegistry) { + defaultRegistry = new StepParserRegistry(); + registerBuiltinStepParsers(defaultRegistry); + } + return defaultRegistry; +} + +/** Test-only: reset the shared registry (so built-in registration can be + * re-exercised in isolation). */ +export function __resetStepParserRegistryForTests(): void { + defaultRegistry = undefined; +} + +// ── Convenience pass-throughs to the default registry ──────────────────────── + +export function registerStepParser(parser: StepParser, opts?: { builtin?: boolean }): void { + getStepParserRegistry().register(parser, opts); +} + +export function getStepParser(id: string): StepParser | undefined { + return getStepParserRegistry().getParser(id); +} + +export function listStepParsers(): StepParser[] { + return getStepParserRegistry().listParsers(); +} + +export function unregisterStepParser(id: string): boolean { + return getStepParserRegistry().unregister(id); +} + +// Register built-ins into the shared registry on import (idempotent via `has`). +registerBuiltinStepParsers(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2322ed9883..352b281004 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,11 +3,63 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; -import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; +import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; -import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js"; +import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; +import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; +import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; +import { + type PluginGateVerdict, + findWorkflowColumn, + resolveColumnPluginGates, +} from "./plugin-gate-verdict.js"; +import { getTraitRegistry, assertColumnTraitsValid } from "./trait-registry.js"; +import { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js"; +import { + OccupiedColumnsError, + assertRehomeTargetValid, + computeRemovedOccupiedColumns, + computeIncompatibleFieldChanges, + IncompatibleFieldChangeError, + resolveEntryColumnId, + resolveSwitchReconciliation, + runReconciliationAbort, +} from "./workflow-reconciliation.js"; +import { + type DefaultWorkflowMoveContext, + applyDefaultWorkflowMoveEffects, + evaluateMergeBlockerGuard, + registerDefaultWorkflowHooks, +} from "./default-workflow-hooks.js"; +import { + type TransitionRejection, + makeTransitionRejection, + makeTransitionPending, +} from "./transition-types.js"; +import { + writeTransitionPending, + clearTransitionPending, + readTransitionPending, + reconcileHooksRemaining, +} from "./transition-pending.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition } from "./workflow-ir-types.js"; +import { + validateCustomFieldPatch, + applyFieldDefaults, + reconcileFieldsOnWorkflowChange, + CustomFieldRejectionError, + type CustomFieldRejection, +} from "./task-fields.js"; +// Side-effect import: registers the 14 built-in trait DEFINITIONS into the +// shared trait registry on load (the flag-ON path resolves traits by id). +import "./builtin-traits.js"; +// Step-inversion U12 (KTD-12): the legacy `parseStepsFromPrompt` path resolves +// the `step-headings` parser through the registry (proving the registry path), +// staying byte-identical with the direct extracted function. +import { getStepParser } from "./step-parsers.js"; import type { WorkflowDefinition, WorkflowDefinitionInput, @@ -19,8 +71,11 @@ import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "./bu import { WORKFLOW_PARITY_OBSERVED_MUTATION, WORKFLOW_PARITY_DRIFT_MUTATION, + DUAL_ACCEPT_PARITY_MUTATIONS, + computeWorkflowColumnsGraduationReport, type WorkflowParityDiff, type WorkflowParitySummary, + type WorkflowColumnsGraduationReport, } from "./workflow-parity.js"; /** Tags WorkflowStep rows materialized by compiling a workflow so they can be @@ -165,6 +220,7 @@ interface TaskRow { executionCompletedAt: string | null; dependencies: string | null; steps: string | null; + customFields: string | null; log: string | null; attachments: string | null; steeringComments: string | null; @@ -649,7 +705,7 @@ function deepMergeWithNullDelete( export interface TaskStoreEvents { "task:created": [task: Task]; - "task:moved": [data: { task: Task; from: Column; to: Column; source: "user" | "engine" }]; + "task:moved": [data: { task: Task; from: ColumnId; to: ColumnId; source: "user" | "engine" | "scheduler" }]; "task:updated": [task: Task]; "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }]; "task:merged": [result: MergeResult]; @@ -756,6 +812,12 @@ const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([ "agents.md", ]); +// `parseStepHeadings` (the `### Step N:` parser, step-inversion U1) was extracted +// into `step-parsers.ts` as the `step-headings` built-in parser (U12, KTD-12). +// It is re-exported here for back-compat with callers/tests that import it from +// `store.ts`. `parseStepsFromPrompt` below delegates through the registry. +export { parseStepHeadings } from "./step-parsers.js"; + export function isValidFileScopeEntry(token: string): boolean { const trimmed = token.trim(); if (!trimmed) return false; @@ -1039,7 +1101,7 @@ export class InvalidMergeQueueLeaseDurationError extends Error { export class HandoffInvariantViolationError extends Error { constructor( public readonly taskId: string, - public readonly fromColumn: Column, + public readonly fromColumn: ColumnId, message: string, ) { super(message); @@ -1047,15 +1109,58 @@ export class HandoffInvariantViolationError extends Error { } } +/** + * Thrown by the flag-ON (`workflowColumns`) `moveTaskInternal` path when a move + * is rejected, carrying the typed {@link TransitionRejection} (KTD-3/R13). The + * existing callers of `moveTask` catch thrown `Error`s (e.g. the dashboard move + * route inspects `err.message`), so the rejection rides on an `Error` subclass + * — `.message` reproduces the legacy human-readable string so flag-ON callers + * that only read the message keep working, while `.rejection` exposes the + * machine-stable code/messageKey/retryable for surfaces that want it. + * + * The FLAG-OFF path still throws the bare legacy `Error` strings unchanged + * (zero behavior change while the flag is off — proven by the characterization + * suite). + */ +export class TransitionRejectionError extends Error { + readonly rejection: TransitionRejection; + constructor(rejection: TransitionRejection, message: string) { + super(message); + this.name = "TransitionRejectionError"; + this.rejection = rejection; + } +} + interface MoveTaskOptions { preserveResumeState?: boolean; preserveProgress?: boolean; preserveWorktree?: boolean; preserveStatus?: boolean; allocateWorktree?: (reservedNames: Set) => string | null; - moveSource?: "user" | "engine"; + moveSource?: "user" | "engine" | "scheduler"; skipMergeBlocker?: boolean; allowDirectInReviewMove?: boolean; + /** + * KTD-9: engine/recovery moves bypass trait guards and abort-on-exit effects + * (the generalization of `skipMergeBlocker`). It NEVER bypasses capacity + * (KTD-10). Engine-internal only: HTTP move endpoints hardcode it off and must + * never forward a caller-supplied value (mirrors the hardcoded + * `moveSource: "user"` posture). When unset, the flag-ON path derives it from + * `moveSource === "engine"` plus `skipMergeBlocker`. + */ + bypassGuards?: boolean; + /** + * U5 (R15/R20): a workflow-reconciliation re-home move (switch/edit/delete). + * Unlike `bypassGuards` (which skips trait guards but still enforces the + * column-graph adjacency, so the U4 parity matrix is unaffected), a recovery + * re-home must reach the new workflow's entry column from ANY current column — + * a card that would otherwise be stranded in a column its (new) workflow does + * not define. So this additionally skips the adjacency check (step 2). The + * structural unknown-column check (step 1) and the in-txn capacity check + * (KTD-10) still apply. Engine-internal only: never forwarded from an HTTP + * endpoint. When set, implies `bypassGuards`. + */ + recoveryRehome?: boolean; } interface MoveTaskInternalOptions { @@ -1068,6 +1173,12 @@ interface MoveTaskInternalOptions { export class TaskStore extends EventEmitter { private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL'; + /** U6: sentinel effective-workflow id for default-workflow (null-selection) + * tasks, so they all share one per-column capacity pool (KTD-10). It is not a + * real workflow row id (no `builtin:`/custom collision possible). Re-exposed + * as a static member for internal call sites; the canonical const lives in + * `workflow-capacity.ts` (`DEFAULT_WORKFLOW_POOL_ID`). */ + private static readonly DEFAULT_WORKFLOW_POOL_ID = DEFAULT_WORKFLOW_POOL_ID; static async getOrCreateForProject( projectId?: string, @@ -1134,6 +1245,15 @@ export class TaskStore extends EventEmitter { private watcher: FSWatcher | null = null; /** In-memory cache of tasks for diffing watcher events */ private taskCache: Map = new Map(); + /** + * U8 (KTD-2): pre-evaluated plugin gate verdicts, keyed `taskId` → `toColumn` + * → recorded verdicts (one per plugin gate trait). A plugin gate is evaluated + * OUTSIDE the lock by the engine's trait adapter; the verdict is recorded here + * and re-checked cheaply in-lock at move time so plugin code never blocks or + * wedges the task lock. Kept in-memory (minimal/surgical per U8); the + * `plugin-gate-verdict.ts` seam can later back this with SQLite. + */ + private pluginGateVerdicts: Map> = new Map(); /** Paths recently written by in-process mutations (suppresses duplicate events) */ private recentlyWritten: Set = new Set(); /** Pending debounce timers keyed by task ID */ @@ -1395,7 +1515,14 @@ export class TaskStore extends EventEmitter { async init(): Promise { await mkdir(this.tasksDir, { recursive: true }); - + + // U4: register the default-workflow trait hook implementations into the + // shared trait registry (the flag-ON moveTaskInternal path resolves the + // legacy per-column effects through these). Idempotent; built-in trait + // DEFINITIONS self-register on import of ./builtin-traits.js (pulled in + // transitively via default-workflow-hooks / trait-registry). + registerDefaultWorkflowHooks(); + // Initialize SQLite database if (!this._db) { // Startup corruption guard: before opening, detect a malformed fusion.db @@ -1493,6 +1620,31 @@ export class TaskStore extends EventEmitter { error: err instanceof Error ? err.message : String(err), }); } + + // U12: workflow-columns integrity pass. When the flag is ON, audit + re-home + // any task whose stored column is no longer valid in its resolved workflow + // (KTD-1 guarantees zero rewrites for healthy legacy rows, so this is a + // no-op for the common case). Idempotent; non-fatal — never blocks startup. + try { + const settings = await this.getSettingsFast(); + if (isWorkflowColumnsEnabled(settings)) { + await this.runWorkflowColumnsIntegrityPass(); + // #1401: recover any transitionPending markers stranded by a crash + // between the in-txn write and the post-commit clear (they otherwise + // permanently inflate capacity counts for their target column). + await this.recoverStaleTransitionPending(); + } else { + // #1409: flag-OFF init — evacuate any card stuck in a non-legacy column + // (e.g. the flag was toggled OFF out-of-process while a card sat in a + // custom column) so the board stays listable and moves work. + await this.evacuateCustomColumnsToLegacy("flag-off-init"); + } + } catch (err) { + storeLog.warn("workflowColumns integrity pass failed during init", { + phase: "init:workflow-columns-integrity", + error: err instanceof Error ? err.message : String(err), + }); + } } // ── Row <-> Task Conversion ──────────────────────────────────────── @@ -1565,6 +1717,7 @@ export class TaskStore extends EventEmitter { executionCompletedAt: row.executionCompletedAt || undefined, dependencies: fromJson(row.dependencies) || [], steps: fromJson(row.steps) || [], + customFields: fromJson>(row.customFields) ?? undefined, log: fromJson(row.log) || [], tokenBudgetSoftAlertedAt: row.tokenBudgetSoftAlertedAt || undefined, tokenBudgetHardAlertedAt: row.tokenBudgetHardAlertedAt || undefined, @@ -1712,6 +1865,7 @@ export class TaskStore extends EventEmitter { dependencies: entry.dependencies ?? [], steps: entry.steps ?? [], currentStep: entry.currentStep ?? 0, + customFields: entry.customFields ?? undefined, size: entry.size, reviewLevel: entry.reviewLevel, prInfo: slim ? undefined : entry.prInfo, @@ -1844,6 +1998,7 @@ export class TaskStore extends EventEmitter { dependencies: task.dependencies, steps: task.steps, currentStep: task.currentStep, + customFields: task.customFields, size: task.size, reviewLevel: task.reviewLevel, prInfo: task.prInfo, @@ -2052,7 +2207,7 @@ export class TaskStore extends EventEmitter { "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", - "dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", + "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", @@ -2101,7 +2256,7 @@ export class TaskStore extends EventEmitter { "error", "summary", "thinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", - "dependencies", "steps", "attachments", "steeringComments", + "dependencies", "steps", "customFields", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", @@ -2203,6 +2358,7 @@ export class TaskStore extends EventEmitter { task.executionCompletedAt ?? null, toJson(task.dependencies || []), toJson(task.steps || []), + toJson(task.customFields ?? {}), toJson(task.log || []), toJson(task.attachments || []), toJson(task.steeringComments || []), @@ -2270,7 +2426,7 @@ export class TaskStore extends EventEmitter { summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt, firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt, - dependencies, steps, log, attachments, steeringComments, + dependencies, steps, customFields, log, attachments, steeringComments, comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, mergeDetails, breakIntoSubtasks, noCommitsExpected, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection @@ -2297,7 +2453,7 @@ export class TaskStore extends EventEmitter { summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt, firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt, - dependencies, steps, log, attachments, steeringComments, + dependencies, steps, customFields, log, attachments, steeringComments, comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, mergeDetails, breakIntoSubtasks, noCommitsExpected, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection @@ -2372,6 +2528,7 @@ export class TaskStore extends EventEmitter { executionCompletedAt = excluded.executionCompletedAt, dependencies = excluded.dependencies, steps = excluded.steps, + customFields = excluded.customFields, log = excluded.log, attachments = excluded.attachments, steeringComments = excluded.steeringComments, @@ -3251,6 +3408,20 @@ export class TaskStore extends EventEmitter { const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings; this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged }); + // #1409: if this update flipped workflowColumns ON→OFF, evacuate any card + // stranded in a custom (non-legacy) column back to a legacy column so the + // board stays listable / movable on the legacy path. + if (isWorkflowColumnsEnabled(previousMerged) && !isWorkflowColumnsEnabled(updatedMerged)) { + try { + await this.evacuateCustomColumnsToLegacy("flag-toggled-off"); + } catch (err) { + storeLog.warn("workflowColumns ON→OFF evacuation failed", { + phase: "evacuate-custom-columns", + error: err instanceof Error ? err.message : String(err), + }); + } + } + // Bootstrap project memory file when memory is toggled on if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) { try { @@ -3351,6 +3522,21 @@ export class TaskStore extends EventEmitter { // Emit settings:updated so SSE listeners pick up the change this.emit("settings:updated", { settings: merged, previous }); + + // #1409: workflowColumns lives in experimentalFeatures (a global key), so the + // ON→OFF toggle flows through here. Evacuate any card stranded in a custom + // column when the flag flips off. + if (isWorkflowColumnsEnabled(previous) && !isWorkflowColumnsEnabled(merged)) { + try { + await this.evacuateCustomColumnsToLegacy("flag-toggled-off"); + } catch (err) { + storeLog.warn("workflowColumns ON→OFF evacuation failed", { + phase: "evacuate-custom-columns", + error: err instanceof Error ? err.message : String(err), + }); + } + } + return merged; } @@ -4245,7 +4431,7 @@ export class TaskStore extends EventEmitter { id: candidate.id, title: candidate.title ?? "", description: candidate.description, - column: "todo" as Column, + column: "todo", createdAt: Date.parse(candidate.createdAt), sourceAgentId: candidate.sourceAgentId, sourceParentTaskId: null, @@ -4730,8 +4916,9 @@ export class TaskStore extends EventEmitter { * from each row to make list responses cheap for board-style consumers. Detail fields default * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ slim?: boolean; - /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */ - column?: Column; + /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). + * Widened to {@link ColumnId} (#1403) so custom-column filters are accepted. */ + column?: ColumnId; /** Opt-in startup-only memo for repeated slim reads during boot choreography. */ startupMemo?: boolean; }): Promise { @@ -4885,6 +5072,270 @@ export class TaskStore extends EventEmitter { return sorted.slice(offset, offset + Math.max(0, limit)); } + /** + * Residual B (U13/U9): per-branch progress snapshots for the given tasks, + * read from the `workflow_run_branches` table. Used to populate the optional + * additive `branchProgress` field on the board task payload so U9's parallel- + * window badge can render. Cheap and additive: + * - returns an empty map immediately when the table is empty (the common + * case — no fan-out runs in flight); + * - one query for the whole task batch (no per-card N+1); + * - returns only the LATEST run's branches per task (a card is in exactly + * one parallel window at a time — KTD-11 one-card-one-position). + * Never throws on a missing/legacy table (additive guard). + */ + getBranchProgressByTask( + taskIds: readonly string[], + ): Map> { + const result = new Map>(); + if (taskIds.length === 0) return result; + try { + // Skip entirely when the table has no rows (cheap existence probe). + const any = this.db + .prepare("SELECT 1 FROM workflow_run_branches LIMIT 1") + .get(); + if (!any) return result; + + const placeholders = taskIds.map(() => "?").join(", "); + // Filter to the latest run per task entirely in SQL (#1413): the + // correlated subquery resolves the winning (updatedAt, runId) pair per + // task — MAX(updatedAt) with a deterministic MAX(runId) tie-break — and + // the JOIN matches both columns so only the latest run's rows are read. + // The runId tie-break makes ties on updatedAt deterministic instead of + // letting an arbitrary historical run win. + const rows = this.db + .prepare( + `SELECT b.taskId AS taskId, b.runId AS runId, b.branchId AS branchId, + b.currentNodeId AS nodeId, b.status AS status, b.updatedAt AS updatedAt + FROM workflow_run_branches b + JOIN ( + -- Resolve the winning run per task: the run owning the row with + -- the greatest updatedAt, with runId as a deterministic + -- tie-break when two runs share an updatedAt. Returns the whole + -- run's rows (all its branches), not just the single max row. + SELECT taskId, runId AS latestRunId + FROM ( + SELECT taskId, runId, + ROW_NUMBER() OVER ( + PARTITION BY taskId + ORDER BY MAX(updatedAt) DESC, runId DESC + ) AS rn + FROM workflow_run_branches + WHERE taskId IN (${placeholders}) + GROUP BY taskId, runId + ) + WHERE rn = 1 + ) latest_run + ON latest_run.taskId = b.taskId + AND latest_run.latestRunId = b.runId + WHERE b.taskId IN (${placeholders})`, + ) + .all(...taskIds, ...taskIds) as Array<{ + taskId: string; + runId: string; + branchId: string; + nodeId: string; + status: string; + updatedAt: string; + }>; + + for (const row of rows) { + const list = result.get(row.taskId) ?? []; + list.push({ branchId: row.branchId, nodeId: row.nodeId, status: row.status }); + result.set(row.taskId, list); + } + } catch { + // Legacy/missing table or query failure — degrade to no branch progress. + return new Map(); + } + return result; + } + + /** + * Persist (idempotent upsert) one branch's progress for a fan-out run (#1407). + * Keyed by (taskId, runId, branchId) — the table PK — so re-running the same + * branch overwrites its single row with the latest currentNodeId/status. The + * executor's crash-resume reads only `status = 'completed'` rows and skips + * those nodes, so resume granularity is keyed by the persisted currentNodeId. + * Additive: silently no-ops on a legacy/missing table. + */ + saveWorkflowRunBranch(state: { + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: string; + }): void { + try { + this.db + .prepare( + `INSERT INTO workflow_run_branches + (taskId, runId, branchId, currentNodeId, status, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(taskId, runId, branchId) DO UPDATE SET + currentNodeId = excluded.currentNodeId, + status = excluded.status, + updatedAt = excluded.updatedAt`, + ) + .run( + state.taskId, + state.runId, + state.branchId, + state.currentNodeId, + state.status, + new Date().toISOString(), + ); + } catch { + // Legacy/missing table — persistence is additive, so degrade silently. + } + } + + /** Load persisted branch states for a run (crash-resume; #1407). */ + loadWorkflowRunBranches( + taskId: string, + runId: string, + ): Array<{ + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: "running" | "completed" | "failed" | "aborted"; + }> { + try { + const rows = this.db + .prepare( + `SELECT taskId, runId, branchId, currentNodeId, status + FROM workflow_run_branches + WHERE taskId = ? AND runId = ?`, + ) + .all(taskId, runId) as Array<{ + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: "running" | "completed" | "failed" | "aborted"; + }>; + return rows; + } catch { + return []; + } + } + + /** + * Prune stale branch rows for a task (#1412). Deletes every row for `taskId` + * whose runId differs from the supplied `keepRunId`, bounding growth across a + * long-lived task's repeated runs. Called on run start and run completion. + * Additive: silently no-ops on a legacy/missing table. + */ + clearWorkflowRunBranches(taskId: string, keepRunId: string): void { + try { + this.db + .prepare( + `DELETE FROM workflow_run_branches WHERE taskId = ? AND runId != ?`, + ) + .run(taskId, keepRunId); + } catch { + // Legacy/missing table — pruning is additive, so degrade silently. + } + } + + /** + * Persist (idempotent upsert) one step instance's run-state inside a foreach + * region (step-inversion U4, KTD-6). Keyed by (taskId, runId, foreachNodeId, + * stepIndex) — the table PK — so re-writing the same instance overwrites its + * single row with the latest currentNodeId/status/anchors. `updatedAt` is + * stamped server-side. Mirrors `saveWorkflowRunBranch`: additive, silently + * no-ops on a legacy/missing table. + */ + saveWorkflowRunStepInstance( + state: import("./types.js").WorkflowRunStepInstance, + ): void { + try { + this.db + .prepare( + `INSERT INTO workflow_run_step_instances + (taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(taskId, runId, foreachNodeId, stepIndex) DO UPDATE SET + pinnedStepCount = excluded.pinnedStepCount, + currentNodeId = excluded.currentNodeId, + status = excluded.status, + baselineSha = excluded.baselineSha, + checkpointId = excluded.checkpointId, + reworkCount = excluded.reworkCount, + branchName = excluded.branchName, + integratedAt = excluded.integratedAt, + updatedAt = excluded.updatedAt`, + ) + .run( + state.taskId, + state.runId, + state.foreachNodeId, + state.stepIndex, + state.pinnedStepCount, + state.currentNodeId ?? null, + state.status, + state.baselineSha ?? null, + state.checkpointId ?? null, + state.reworkCount ?? 0, + state.branchName ?? null, + state.integratedAt ?? null, + new Date().toISOString(), + ); + } catch { + // Legacy/missing table — persistence is additive, so degrade silently. + } + } + + /** + * Load persisted step-instance run-state for a run (crash-resume; KTD-6). + * Ordered by stepIndex so the executor can reconstruct the instance set in + * step order. Additive: returns [] on a legacy/missing table. + */ + loadWorkflowRunStepInstances( + taskId: string, + runId: string, + ): import("./types.js").WorkflowRunStepInstance[] { + try { + const rows = this.db + .prepare( + `SELECT taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt + FROM workflow_run_step_instances + WHERE taskId = ? AND runId = ? + ORDER BY stepIndex ASC`, + ) + .all(taskId, runId) as import("./types.js").WorkflowRunStepInstance[]; + return rows; + } catch { + return []; + } + } + + /** + * Prune step-instance rows for a task (KTD-6, #1412 pattern). When `runId` is + * provided, deletes every row for `taskId` whose runId differs (bounding growth + * across a long-lived task's repeated runs — call on run start/completion). + * When `runId` is omitted, deletes all rows for the task (e.g. on archive). + * Additive: silently no-ops on a legacy/missing table. + */ + clearWorkflowRunStepInstances(taskId: string, keepRunId?: string): void { + try { + if (keepRunId === undefined) { + this.db + .prepare(`DELETE FROM workflow_run_step_instances WHERE taskId = ?`) + .run(taskId); + } else { + this.db + .prepare( + `DELETE FROM workflow_run_step_instances WHERE taskId = ? AND runId != ?`, + ) + .run(taskId, keepRunId); + } + } catch { + // Legacy/missing table — pruning is additive, so degrade silently. + } + } + async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> { const reconcileScanLimit = 200; const offset = Math.max(0, options?.offset ?? 0); @@ -5496,9 +5947,12 @@ export class TaskStore extends EventEmitter { async moveTask( id: string, - toColumn: Column, + toColumn: ColumnId, options?: MoveTaskOptions, ): Promise { + // ColumnId admits workflow-defined custom column ids (KTD-1). Both paths + // runtime-validate: flag-ON against the task's resolved workflow, flag-OFF + // via the VALID_TRANSITIONS lookup (non-legacy ids reject as before). return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn, options, { fromHandoff: false })); } @@ -5533,6 +5987,9 @@ export class TaskStore extends EventEmitter { { ...opts.moveOptions, skipMergeBlocker: true, + // KTD-9: handoff is an engine/recovery-class move; its skipMergeBlocker + // maps onto bypassGuards under the flag (identical behavior both paths). + bypassGuards: true, }, { fromHandoff: true, @@ -5551,7 +6008,7 @@ export class TaskStore extends EventEmitter { private async moveTaskInternal( id: string, - toColumn: Column, + toColumn: ColumnId, options: MoveTaskOptions | undefined, internal: MoveTaskInternalOptions, currentTask?: Task, @@ -5560,6 +6017,30 @@ export class TaskStore extends EventEmitter { const task = currentTask ?? await this.readTaskForMove(id); const moveSource = options?.moveSource ?? "engine"; + // ── U4: flag-gated workflow-resolved transition path (KTD-8) ───────────── + // Flag OFF (default): the legacy `VALID_TRANSITIONS` / inline-side-effect + // path below runs byte-identical (proven by the characterization suite). + // Flag ON: validate against the task's resolved workflow column graph, run + // sync trait guards (unless bypassed), and route the legacy per-column side + // effects through the default-workflow trait hooks. + // `experimentalFeatures` is a global-scoped setting, so the project-only + // `getSettingsSync()` row would miss it — read merged settings (global + + // project) via getSettingsFast(). This is an async read taken before the + // lock-sensitive transaction; it does not touch the task lock. + const mergedSettingsForMove = await this.getSettingsFast(); + const useWorkflow = isWorkflowColumnsEnabled(mergedSettingsForMove); + // bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker + // call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the + // capacity check is not a guard (U6 fills the enforcement; U4 leaves a + // pass-through slot). An explicit option value wins; otherwise derive it. + const bypassGuards = + options?.recoveryRehome === true || + (options?.bypassGuards ?? + (moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true)); + const workflowIr: WorkflowIr | undefined = useWorkflow + ? this.resolveTaskWorkflowIrSync(id) + : undefined; + if (task.column === toColumn) { if (internal.fromHandoff && toColumn === "in-review") { this.db.transactionImmediate(() => { @@ -5616,19 +6097,146 @@ export class TaskStore extends EventEmitter { return task; } - const validTargets = VALID_TRANSITIONS[task.column]; - if (!validTargets.includes(toColumn)) { - throw new Error( - `Invalid transition: '${task.column}' → '${toColumn}'. ` + - `Valid targets: ${validTargets.join(", ") || "none"}`, - ); - } - const fromColumn = task.column; - if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) { - const mergeBlocker = getTaskMergeBlocker(task); - if (mergeBlocker) { - throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`); + + if (useWorkflow && workflowIr) { + // ── Flag-ON validation + sync guards (typed rejections, KTD-3/R13) ───── + // 1. Target column must exist in the task's workflow → unknown-column. + // #1411: a recoveryRehome move to a LEGACY column (todo/archived/…) is + // the engine's self-healing rescue path — those targets are guaranteed + // safe landing columns even when a custom workflow never defined them. + // recoveryRehome already skips adjacency (below); it must likewise skip + // the unknown-column rejection for legacy recovery targets, otherwise a + // custom-workflow card could never be rescued to todo/archived and would + // stay stuck — the exact bug #1411 describes. Non-legacy unknown targets + // still reject (a genuine programming error), and normal (non-recovery) + // moves are unaffected. + const recoveryToLegacy = + options?.recoveryRehome === true && (COLUMNS as readonly string[]).includes(toColumn); + if (!workflowHasColumn(workflowIr, toColumn) && !recoveryToLegacy) { + throw new TransitionRejectionError( + makeTransitionRejection( + "unknown-column", + "transition.rejected.unknownColumn", + false, + `Column '${toColumn}' is not defined in this task's workflow`, + ), + `Invalid transition: '${fromColumn}' → '${toColumn}'. Unknown column for this workflow.`, + ); + } + // 2. Column-graph adjacency. For the default workflow this reproduces + // VALID_TRANSITIONS verbatim (resolveAllowedColumns); the + // transition-parity suite machine-checks the equivalence. A U5 recovery + // re-home (recoveryRehome) skips this so a stranded card can reach its + // new workflow's entry column from any current column. + const allowed = resolveAllowedColumns(workflowIr, fromColumn); + if (options?.recoveryRehome !== true && !allowed.includes(toColumn)) { + throw new TransitionRejectionError( + makeTransitionRejection( + "guard-rejected", + "transition.rejected.invalidTransition", + false, + `Valid targets: ${allowed.join(", ") || "none"}`, + ), + `Invalid transition: '${fromColumn}' → '${toColumn}'. ` + + `Valid targets: ${allowed.join(", ") || "none"}`, + ); + } + // 3. Sync trait guards (in-lock). Skipped entirely when bypassGuards + // (engine/recovery moves, KTD-9). The default workflow's merge-blocker + // trait reads the same getTaskMergeBlocker. + if (!bypassGuards) { + const guardReason = evaluateMergeBlockerGuard(task, fromColumn, toColumn); + if (guardReason) { + throw new TransitionRejectionError( + makeTransitionRejection( + "merge-blocked", + "transition.rejected.mergeBlocked", + true, + guardReason, + ), + `Cannot move ${id} to done: ${guardReason}`, + ); + } + // 4. Plugin gate verdict re-check (U8, KTD-2). For each PLUGIN gate trait + // on the target column, consume the pre-evaluated verdict (recorded by + // the engine's trait adapter outside the lock). A blocking gate with + // no recorded `allow` verdict fails closed (typed rejection); advisory + // gates record-and-allow. Built-in gates are handled by their own + // path; this guard is the plugin gate surface only. + const registry = getTraitRegistry(); + const pluginGates = resolveColumnPluginGates( + findWorkflowColumn(workflowIr, toColumn), + (tid) => registry.getTrait(tid), + ); + if (pluginGates.length > 0) { + const recorded = this.consumePluginGateVerdicts(id, toColumn); + const byTrait = new Map(recorded.map((v) => [v.traitId, v])); + for (const gate of pluginGates) { + if (gate.gateMode === "advisory") continue; // record-and-allow + // Degraded (force-disabled) plugin gate: its hook impl is gone, so + // the registry resolves it to a no-op + audit warning (KTD-7). A + // degraded gate is PASSIVE — the column never blocks the card; the + // registry's warning is the audit signal. Cards remain movable. + const resolved = registry.resolveTraitHook(gate.traitId, "gate"); + if (resolved.warning) continue; + const verdict = byTrait.get(gate.traitId); + // Fail closed: a blocking gate with no recorded allow verdict rejects. + if (!verdict || !verdict.allow) { + const reason = + verdict?.detail ?? + (verdict + ? `Gate '${gate.traitId}' did not pass` + : `Gate '${gate.traitId}' has not been evaluated for this move`); + throw new TransitionRejectionError( + makeTransitionRejection( + "merge-blocked", + "transition.rejected.gateBlocked", + true, + reason, + ), + `Cannot move ${id} to '${toColumn}': ${reason}`, + ); + } + } + } + } + } else { + // ── Flag-OFF legacy path (unchanged) ─────────────────────────────────── + // A task can sit in a custom column when the flag was toggled ON→OFF; + // `VALID_TRANSITIONS` only keys the legacy columns, so a missing entry + // degrades to the legacy "Invalid transition" error instead of a TypeError. + // #1409: flag-OFF evacuation. A recoveryRehome move OUT of a non-legacy + // (custom) column into a legacy target is the ON→OFF evacuation path — + // `VALID_TRANSITIONS` never keys a custom source column, so the legacy + // check below would strand the card forever. Allow it through (bypassing + // only the adjacency check; this is unreachable for normal flag-OFF moves, + // which never set recoveryRehome and always start from a legacy column, so + // characterization behavior is byte-identical). + const sourceIsLegacy = (COLUMNS as readonly string[]).includes(task.column); + const isEvacuation = + options?.recoveryRehome === true && + !sourceIsLegacy && + (COLUMNS as readonly string[]).includes(toColumn); + if (!isEvacuation) { + // Legacy flag-OFF branch (useWorkflow === false): both columns are + // guaranteed legacy ids here — a non-legacy `toColumn` returns `?? []` + // and rejects below, and flag-OFF tasks never hold custom column ids. + // The `as Column` is provably safe within this branch (#1403). + const validTargets = VALID_TRANSITIONS[task.column as Column] ?? []; + if (!validTargets.includes(toColumn as Column)) { + throw new Error( + `Invalid transition: '${task.column}' → '${toColumn}'. ` + + `Valid targets: ${validTargets.join(", ") || "none"}`, + ); + } + } + + if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) { + const mergeBlocker = getTaskMergeBlocker(task); + if (mergeBlocker) { + throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`); + } } } @@ -5642,106 +6250,153 @@ export class TaskStore extends EventEmitter { task.columnMovedAt = movedAt; task.updatedAt = movedAt; - if (fromColumn === "in-progress" && toColumn !== "in-progress") { - const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt); - const segmentEndMs = Date.parse(task.columnMovedAt); - const segmentDeltaMs = - Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs) - ? Math.max(0, segmentEndMs - segmentStartMs) - : 0; - task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; - } - - if (toColumn === "in-progress") { - task.cumulativeActiveMs ??= 0; - if (!task.firstExecutionAt) { - task.firstExecutionAt = task.columnMovedAt; - } - if (!task.executionStartedAt) { - task.executionStartedAt = task.columnMovedAt; - } - task.userPaused = undefined; - } - if (toColumn === "done" && !task.executionCompletedAt) { - task.executionCompletedAt = task.columnMovedAt; - } - - if (toColumn === "done") { - this.clearDoneTransientFields(task); - } - - const isReopenToTodoOrTriage = - (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") - && (toColumn === "todo" || toColumn === "triage"); - - if (isReopenToTodoOrTriage) { - if (!options?.preserveStatus) { - task.status = undefined; - task.error = undefined; - task.pausedReason = undefined; - } - task.blockedBy = undefined; - task.overlapBlockedBy = undefined; - task.paused = undefined; - task.pausedByAgentId = undefined; - if (moveSource === "user" && toColumn === "todo") { - task.userPaused = true; - } else { - task.userPaused = undefined; - } - + if (useWorkflow) { + // ── Flag-ON: route the legacy per-column side effects through the + // default-workflow trait hooks (timing, reset-on-entry, abort-on-exit, + // merge.onEnter). "Moved, not duplicated" applies to this path; the + // flag-off branch below keeps the legacy inline code verbatim. ─────── + const ctx: DefaultWorkflowMoveContext = { + task, + fromColumn, + toColumn, + moveSource, + bypassGuards, + movedAt, + settings: settingsForInReview, + options: { + preserveStatus: options?.preserveStatus, + preserveResumeState: options?.preserveResumeState, + preserveProgress: options?.preserveProgress, + preserveWorktree: options?.preserveWorktree, + }, + resetSteps: () => this.resetAllStepsToPending(task), + }; + const isReopenToTodoOrTriage = + (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") && + (toColumn === "todo" || toColumn === "triage"); const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); const preserveStepProgress = - options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress); - - if (!options?.preserveWorktree) { - task.worktree = undefined; + options?.preserveResumeState || + (options?.preserveProgress === true && hasNonPendingStepProgress); + const { warnings } = applyDefaultWorkflowMoveEffects(ctx); + for (const warning of warnings) { + storeLog.warn("Default-workflow trait hook degraded to no-op", { + phase: "moveTaskInternal:workflow-hooks", + taskId: id, + ...warning, + }); } - - if (!options?.preserveResumeState) { - task.executionStartedAt = undefined; - task.executionCompletedAt = undefined; - } else { - task.executionCompletedAt = undefined; + // Store-owned effects the hooks intentionally do NOT perform (filesystem / + // store-private): clearing done transient fields + prompt-checkbox reset. + if (toColumn === "done") { + this.clearDoneTransientFields(task); } - - if (!preserveStepProgress) { - this.resetAllStepsToPending(task); + if (isReopenToTodoOrTriage && !preserveStepProgress) { await this.resetPromptCheckboxes(dir); } - } - - if (toColumn === "in-review") { - if (task.autoMerge === undefined && settingsForInReview) { - task.autoMerge = settingsForInReview.autoMerge; + } else { + // ── Flag-OFF legacy inline side effects (UNCHANGED — the flag-off path) ── + if (fromColumn === "in-progress" && toColumn !== "in-progress") { + const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt); + const segmentEndMs = Date.parse(task.columnMovedAt); + const segmentDeltaMs = + Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs) + ? Math.max(0, segmentEndMs - segmentStartMs) + : 0; + task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; } - task.recoveryRetryCount = undefined; - task.nextRecoveryAt = undefined; - // Clear scheduler-side dispatch state: `queued`, `blockedBy`, and - // `overlapBlockedBy` are stamped while the task waits in `todo`. If - // they survive the transition into `in-review` they permanently block - // the merge gate (see getTaskMergeBlocker's BLOCKING_TASK_STATUSES). - if (task.status === "queued") { - task.status = undefined; + + if (toColumn === "in-progress") { + task.cumulativeActiveMs ??= 0; + if (!task.firstExecutionAt) { + task.firstExecutionAt = task.columnMovedAt; + } + if (!task.executionStartedAt) { + task.executionStartedAt = task.columnMovedAt; + } + task.userPaused = undefined; + } + if (toColumn === "done" && !task.executionCompletedAt) { + task.executionCompletedAt = task.columnMovedAt; } - task.blockedBy = undefined; - task.overlapBlockedBy = undefined; - } - if ( - (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) - || (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) - ) { - task.workflowStepResults = undefined; - } + if (toColumn === "done") { + this.clearDoneTransientFields(task); + } - if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { - task.branch = undefined; - task.executionStartBranch = undefined; - task.baseCommitSha = undefined; - task.summary = undefined; - task.recoveryRetryCount = undefined; - task.nextRecoveryAt = undefined; + const isReopenToTodoOrTriage = + (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") + && (toColumn === "todo" || toColumn === "triage"); + + if (isReopenToTodoOrTriage) { + if (!options?.preserveStatus) { + task.status = undefined; + task.error = undefined; + task.pausedReason = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; + task.paused = undefined; + task.pausedByAgentId = undefined; + if (moveSource === "user" && toColumn === "todo") { + task.userPaused = true; + } else { + task.userPaused = undefined; + } + + const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); + const preserveStepProgress = + options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress); + + if (!options?.preserveWorktree) { + task.worktree = undefined; + } + + if (!options?.preserveResumeState) { + task.executionStartedAt = undefined; + task.executionCompletedAt = undefined; + } else { + task.executionCompletedAt = undefined; + } + + if (!preserveStepProgress) { + this.resetAllStepsToPending(task); + await this.resetPromptCheckboxes(dir); + } + } + + if (toColumn === "in-review") { + if (task.autoMerge === undefined && settingsForInReview) { + task.autoMerge = settingsForInReview.autoMerge; + } + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + // Clear scheduler-side dispatch state: `queued`, `blockedBy`, and + // `overlapBlockedBy` are stamped while the task waits in `todo`. If + // they survive the transition into `in-review` they permanently block + // the merge gate (see getTaskMergeBlocker's BLOCKING_TASK_STATUSES). + if (task.status === "queued") { + task.status = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; + } + + if ( + (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) + || (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) + ) { + task.workflowStepResults = undefined; + } + + if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { + task.branch = undefined; + task.executionStartBranch = undefined; + task.baseCommitSha = undefined; + task.summary = undefined; + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + } } if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) { @@ -5769,6 +6424,41 @@ export class TaskStore extends EventEmitter { return; } + // ── U6: in-txn capacity enforcement (KTD-10) ────────────────────────── + // WIP limits are trait *config*; enforcement is a substrate capability + // that runs HERE, inside the move transaction, so two holds releasing into + // one slot serialize — exactly one commits, the other rejects and retries + // next sweep. It is NOT a guard: it runs regardless of bypassGuards / + // recoveryRehome / moveSource (engine/recovery/scheduler moves honor it + // too). Only a real column change into a capacity-bearing column is gated; + // same-column no-ops were returned earlier. The count is taken with the + // moving task EXCLUDED and the prospective slot it is about to occupy + // added back implicitly (it must fit alongside existing holders), so a + // full column (occupants == limit) rejects. + if (useWorkflow && workflowIr && fromColumn !== toColumn) { + const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove); + if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { + const workflowId = this.resolveEffectiveWorkflowIdSync(id); + const occupants = this.countActiveInCapacitySlotSync({ + targetColumn: toColumn, + workflowId, + countPending: capacity.countPending, + excludeTaskId: id, + }); + if (occupants >= capacity.limit) { + throw new TransitionRejectionError( + makeTransitionRejection( + "capacity-exhausted", + "transition.rejected.capacityExhausted", + true, + `Column '${toColumn}' is at capacity (${occupants}/${capacity.limit})`, + ), + `Cannot move ${id} to '${toColumn}': column at capacity (${occupants}/${capacity.limit})`, + ); + } + } + } + this.upsertTaskWithFtsRecovery(task); this.insertRunAuditEventRow({ taskId: id, @@ -5785,6 +6475,22 @@ export class TaskStore extends EventEmitter { }); this.dequeueMergeQueueOnColumnExit(id, fromColumn, toColumn, movedAt); + // U4 (flag-ON): write the crash-safe transitionPending marker in the SAME + // transaction as the column change (KTD-2). It records the post-commit + // hooks that still owe idempotent execution so a crash mid-transition is + // recoverable from SQLite (the authoritative store, ADR-0001). The store + // clears it immediately after the post-commit hook runner completes + // (below). For the default workflow the field effects already applied + // in-lock; the marker guards the post-commit completion so recovery never + // double-runs (idempotent) and never strands the card. + if (useWorkflow) { + writeTransitionPending( + this.db, + id, + makeTransitionPending(toColumn, ["default-workflow:postCommit"], Date.parse(movedAt) || Date.now()), + ); + } + if (toColumn === "in-review" && !internal.fromHandoff && options?.allowDirectInReviewMove !== true) { this.insertRunAuditEventRow({ taskId: id, @@ -5859,12 +6565,152 @@ export class TaskStore extends EventEmitter { if (this.isWatching) this.taskCache.set(id, { ...task }); + // U4 (flag-ON): post-commit hook completion. The default-workflow field + // effects already ran in-lock and committed; the post-commit phase here is + // the fire-and-forget hook runner per KTD-2. It is idempotent and clears the + // transitionPending marker once done. A crash before this point leaves the + // marker for the recovery sweep to re-run (re-running is a no-op for the + // default workflow's already-committed field effects). + // + // Residual C (U8): AFTER the built-in effects, invoke registered PLUGIN + // onExit (from column) / onEnter (to column) trait hook impls, recording + // per-hook completion in the marker's hooksRemaining. A throwing plugin hook + // DEGRADES (audit) and never wedges the lock or strands the marker — the + // marker is always cleared at the end regardless of hook failures. + if (useWorkflow) { + // Plugin hooks are skipped on engine/recovery-sourced moves (KTD-9 — those + // bypass trait effects) and on same-column no-ops. + if (!bypassGuards && fromColumn !== toColumn && workflowIr) { + try { + await this.runPluginColumnTransitionHooks(id, workflowIr, fromColumn, toColumn); + } catch (err) { + // The runner itself swallows per-hook failures; this is a final guard + // so a runner-level fault never strands the marker. + storeLog.warn("Plugin column transition hook runner faulted (degraded)", { + phase: "moveTaskInternal:plugin-hooks", + taskId: id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + try { + clearTransitionPending(this.db, id); + } catch { + // Clearing is best-effort; the marker recovery sweep is the backstop. + } + } + if (fromColumn !== toColumn) { this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); } return task; } + /** + * Residual C (U8): run registered PLUGIN onExit (from column) / onEnter (to + * column) trait hook impls AFTER the built-in default-workflow effects, on the + * post-commit path. Plugin hooks are async-only (KTD-7) and route through the + * registry's resolved impl (the engine wires `runCustomNode` in via the trait + * adapter; an unregistered/degraded hook resolves to a no-op + audit warning). + * + * Per-hook completion is recorded in the `transitionPending` marker's + * `hooksRemaining` so a crash mid-hook is recoverable. A hook that THROWS is + * audited (`plugin:trait-hook-degraded`) and treated as completed (removed + * from `hooksRemaining`) — a misbehaving plugin never wedges the task lock or + * strands the card (KTD-2 degraded-not-stranded posture). The caller clears + * the marker after this returns. + */ + private async runPluginColumnTransitionHooks( + taskId: string, + workflowIr: WorkflowIr, + fromColumn: string, + toColumn: string, + ): Promise { + const registry = getTraitRegistry(); + // Collect (traitId, hookKind) pairs: onExit for from-column plugin traits, + // onEnter for to-column plugin traits. Only plugin-namespaced traits (KTD-7). + const pending: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = []; + const fromCol = findWorkflowColumn(workflowIr, fromColumn); + for (const ct of fromCol?.traits ?? []) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = registry.getTrait(ct.trait); + if (def?.hooks?.onExit) pending.push({ traitId: ct.trait, hookKind: "onExit" }); + } + const toCol = findWorkflowColumn(workflowIr, toColumn); + for (const ct of toCol?.traits ?? []) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = registry.getTrait(ct.trait); + if (def?.hooks?.onEnter) pending.push({ traitId: ct.trait, hookKind: "onEnter" }); + } + if (pending.length === 0) return; + + // Record the plugin hooks in the marker's hooksRemaining (alongside the + // default-workflow:postCommit marker already written in-txn) so a crash + // mid-hook is recoverable. + const hookIds = pending.map((p) => `${p.traitId}:${p.hookKind}`); + const startedAt = Date.now(); + try { + writeTransitionPending( + this.db, + taskId, + makeTransitionPending(toColumn, ["default-workflow:postCommit", ...hookIds], startedAt), + ); + } catch { + // Marker bookkeeping is best-effort; proceed to run the hooks regardless. + } + + // Read the task once for hook context. MUST be a non-locking read — this + // runs inside `withTaskLock`, so `getTask` (which re-acquires the lock) + // would deadlock. `readTaskFromDb` is the in-lock-safe read. + const taskRow = this.readTaskFromDb(taskId, { includeDeleted: false }); + const taskDetail = taskRow as unknown as TaskDetail | undefined; + + const remaining = ["default-workflow:postCommit", ...hookIds]; + for (const { traitId, hookKind } of pending) { + const resolved = registry.resolveTraitHook(traitId, hookKind); + if (resolved.warning) { + // Degraded (no impl / force-disabled) → passive no-op, audit the warning. + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `plugin-trait-hook-${traitId}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "plugin:trait-hook-degraded", + target: taskId, + metadata: { traitId, hookKind, reason: "no-impl", message: resolved.warning.message }, + }); + } else if (resolved.impl) { + try { + await resolved.impl({ task: taskDetail, context: { fromColumn, toColumn, hookKind } }); + } catch (err) { + // A throwing plugin hook DEGRADES — audited, never wedges the lock. + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `plugin-trait-hook-${traitId}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "plugin:trait-hook-degraded", + target: taskId, + metadata: { + traitId, + hookKind, + reason: "threw", + error: err instanceof Error ? err.message : String(err), + }, + }); + } + } + // Mark this hook complete in the marker (whether it ran, degraded, or threw). + const idx = remaining.indexOf(`${traitId}:${hookKind}`); + if (idx >= 0) remaining.splice(idx, 1); + try { + writeTransitionPending(this.db, taskId, makeTransitionPending(toColumn, remaining, startedAt)); + } catch { + // Best-effort progress bookkeeping; the final clear is the backstop. + } + } + } + private resetAllStepsToPending(task: Task): void { if (task.steps.length === 0) { return; @@ -6060,12 +6906,64 @@ export class TaskStore extends EventEmitter { async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise { return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); } + /** + * Merge a validated/normalized custom-field patch into the existing values. + * `null` in the patch deletes that field's value (the delete sentinel from + * {@link validateCustomFieldPatch}); any other value overwrites. Returns a new + * object (never mutates the input) so the caller assigns it onto the task. + */ + private mergeCustomFieldPatch( + current: Record | undefined, + patch: Record, + ): Record { + const next: Record = { ...(current ?? {}) }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + } + return next; + } + + /** + * Single write authority for custom task fields (U11 / KTD-13). + * + * Resolves the task's workflow field definitions, validates `patch` against + * them via {@link validateCustomFieldPatch}, merges the normalized result into + * `Task.customFields` (delete-on-null), persists through the standard update + * path, and emits `task:updated` like every other task mutation. A workflow + * with no fields (e.g. the default) rejects any non-empty patch with + * `no-fields-defined`. Returns a typed result rather than throwing so callers + * (agent tools, HTTP routes) can surface the field path/code directly. + */ + async updateTaskCustomFields( + taskId: string, + patch: Record, + runContext?: RunMutationContext, + ): Promise<{ ok: true; task: Task } | { ok: false; rejection: CustomFieldRejection }> { + return this.withTaskLock(taskId, async () => { + const defs = this.resolveTaskCustomFieldDefsSync(taskId); + const result = validateCustomFieldPatch(defs, patch); + if (!result.ok) { + return { ok: false as const, rejection: result.rejection }; + } + // Pass the validated PATCH through (with null delete-sentinels) — the + // merge-with-delete happens once, inside updateTaskUnlocked, against the + // freshly-read task. Pre-merging here would lose the delete semantics on + // the second merge. + const task = await this.updateTaskUnlocked(taskId, { customFields: result.normalized }, runContext); + return { ok: true as const, task }; + }); + } + /** * The body of {@link updateTask} WITHOUT acquiring the per-task lock. Callers * that already hold `withTaskLock(id)` — e.g. workflow-selection mutations @@ -6174,6 +7072,19 @@ export class TaskStore extends EventEmitter { } } if (updates.steps !== undefined) task.steps = updates.steps; + // U11/KTD-13: customFields writes are validated against the task's workflow + // field schema through the single authority (task-fields.ts). The patch is + // merged into the existing values (delete-on-null), mirroring + // updateTaskCustomFields. Backward-compat note: U4 round-tripped the object + // opaquely; the field system now enforces type/enum/unknown-id rules, so a + // write against a workflow with no fields (the default) is rejected with a + // typed CustomFieldRejectionError rather than silently persisted. + if (updates.customFields !== undefined) { + const defs = this.resolveTaskCustomFieldDefsSync(id); + const result = validateCustomFieldPatch(defs, updates.customFields); + if (!result.ok) throw new CustomFieldRejectionError(result.rejection); + task.customFields = this.mergeCustomFieldPatch(task.customFields, result.normalized); + } if (updates.currentStep !== undefined) task.currentStep = updates.currentStep; if (updates.status === null) { task.status = undefined; @@ -6823,13 +7734,27 @@ export class TaskStore extends EventEmitter { id: string, stepIndex: number, status: import("./types.js").StepStatus, + options?: { source?: "graph" }, ): Promise { + // Step-inversion projection discipline (U6/KTD-7). A `source: "graph"` write + // is the workflow-graph executor projecting a foreach instance's lifecycle + // (in-progress / done / pending) onto Task.steps[] with EXPLICIT indices. Three + // behaviors diverge from the legacy (default) write: + // (a) the out-of-order-done guard relaxes from strict index order to + // DEPENDENCY order (a done write is legal when every dependsOn step — + // default: the immediately-preceding step — is done/skipped, KTD-11); + // (b) a guard that DOES suppress a graph write logs an audit warning loudly + // (legacy stays silent — a graph suppression is a projection bug); + // (c) the auto-reinit-from-PROMPT.md path is bypassed (the graph pinned the + // step count at foreach expansion; re-parsing here would desync, KTD-3). + const graphSource = options?.source === "graph"; return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); - // Auto-initialize steps from PROMPT.md if empty - if (task.steps.length === 0) { + // Auto-initialize steps from PROMPT.md if empty. Bypassed for graph-source + // writes (U6/KTD-3): the graph owns explicit indices pinned at expansion. + if (task.steps.length === 0 && !graphSource) { task.steps = await this.parseStepsFromPrompt(id); } @@ -6866,22 +7791,63 @@ export class TaskStore extends EventEmitter { } if (status === "done") { - for (let i = 0; i < stepIndex; i++) { - const priorStatus = task.steps[i].status; - if (priorStatus === "pending" || priorStatus === "in-progress") { - const ts = new Date().toISOString(); - task.updatedAt = ts; + // The set of predecessor steps that must be done/skipped before this step + // may go done. Legacy: strict index order (every earlier step). Graph: the + // step's dependsOn list (default = the immediately-preceding step when the + // annotation is absent — preserving sequential behavior, KTD-11). + let blockingIndex = -1; + let blockingStatus: import("./types.js").StepStatus | undefined; + if (graphSource) { + const deps = task.steps[stepIndex]?.dependsOn; + const depIndices = + Array.isArray(deps) && deps.length > 0 + ? deps + : stepIndex > 0 + ? [stepIndex - 1] + : []; + for (const i of depIndices) { + const priorStatus = task.steps[i]?.status; + if (priorStatus === "pending" || priorStatus === "in-progress") { + blockingIndex = i; + blockingStatus = priorStatus; + break; + } + } + } else { + for (let i = 0; i < stepIndex; i++) { + const priorStatus = task.steps[i].status; + if (priorStatus === "pending" || priorStatus === "in-progress") { + blockingIndex = i; + blockingStatus = priorStatus; + break; + } + } + } + if (blockingIndex !== -1) { + const ts = new Date().toISOString(); + task.updatedAt = ts; + const kind = graphSource ? "dependency-order" : "out-of-order"; + task.log.push({ + timestamp: ts, + action: + `Ignored ${kind} ${status} for step ${stepIndex} (${task.steps[stepIndex].name}) — ` + + `${graphSource ? "dependency" : "earlier"} step ${blockingIndex} (${task.steps[blockingIndex].name}) is still ${blockingStatus}`, + }); + // Graph-source suppression is a projection bug — surface it loudly in + // the activity log (U6) rather than the legacy silent ignore. + if (graphSource) { task.log.push({ timestamp: ts, action: - `Ignored out-of-order ${status} for step ${stepIndex} (${task.steps[stepIndex].name}) — ` + - `earlier step ${i} (${task.steps[i].name}) is still ${priorStatus}`, + `[integrity-warning] graph-source updateStep suppressed: step ${stepIndex} ` + + `(${task.steps[stepIndex].name}) → done blocked by unmet dependency ` + + `step ${blockingIndex} (${blockingStatus})`, }); - await this.atomicWriteTaskJson(dir, task); - if (this.isWatching) this.taskCache.set(id, { ...task }); - this.emit("task:updated", task); - return task; } + await this.atomicWriteTaskJson(dir, task); + if (this.isWatching) this.taskCache.set(id, { ...task }); + this.emit("task:updated", task); + return task; } } @@ -7392,6 +8358,37 @@ export class TaskStore extends EventEmitter { }; } + /** + * Aggregate the `workflowColumns` flag default-flip criteria (U12, KTD-8) into + * a single graduation report: five-invariant dual-observe parity, the default + * workflow's transition parity vs VALID_TRANSITIONS, and the dual-accept + * marker/column disagreement count (U6, FN-5719). The flip is a FIELD decision + * — this report is the GATE. Does NOT flip the flag; callers inspect `ready` + * and `blockers`. + */ + computeWorkflowColumnsGraduationReport( + options: { since?: string; limit?: number } = {}, + ): WorkflowColumnsGraduationReport { + const limit = options.limit ?? 1000; + const parity = this.getWorkflowParitySummary(options); + const dualAcceptEvents: RunAuditEvent[] = []; + for (const mutationType of DUAL_ACCEPT_PARITY_MUTATIONS) { + dualAcceptEvents.push( + ...this.getRunAuditEvents({ + domain: "database", + mutationType: mutationType as unknown as RunAuditEvent["mutationType"], + startTime: options.since, + limit, + }), + ); + } + return computeWorkflowColumnsGraduationReport({ + parity, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents, + }); + } + enqueueMergeQueue(taskId: string, opts: MergeQueueEnqueueOptions = {}): MergeQueueEntry { let invalidColumn: Column | null = null; const entry = this.db.transactionImmediate(() => { @@ -7492,7 +8489,7 @@ export class TaskStore extends EventEmitter { } } - private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: Column, nextColumn: Column, now: string): void { + private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: ColumnId, nextColumn: ColumnId, now: string): void { if (previousColumn !== "in-review" || nextColumn === "in-review") { return; } @@ -7782,13 +8779,19 @@ export class TaskStore extends EventEmitter { if (!existsSync(promptPath)) return []; const content = await readFile(promptPath, "utf-8"); - const steps: import("./types.js").TaskStep[] = []; - const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm; - let match; - while ((match = stepRegex.exec(content)) !== null) { - steps.push({ name: match[1].trim(), status: "pending" }); + // Step-inversion U12 (KTD-12): delegate to the registry's `step-headings` + // parser (resolved by id, not a direct import) so the registry path is + // proven and stays byte-identical to the extracted function. The parser + // yields `{ name, dependsOn? }`; re-apply the `pending` status here. + const parser = getStepParser("step-headings"); + if (!parser) { + throw new Error("Step parser 'step-headings' is not registered"); } - return steps; + return parser.parse(content).steps.map((s) => + s.dependsOn + ? { name: s.name, status: "pending" as const, dependsOn: s.dependsOn } + : { name: s.name, status: "pending" as const }, + ); } /** @@ -10700,6 +11703,7 @@ export class TaskStore extends EventEmitter { dependencies: entry.dependencies, steps: entry.steps, currentStep: entry.currentStep, + customFields: entry.customFields ?? undefined, size: entry.size, reviewLevel: entry.reviewLevel, prInfo: entry.prInfo, @@ -11219,15 +12223,32 @@ ${stepsSection}`; return {}; } + /** Server-side trait-composition validation (residual A). Throws a typed + * ColumnTraitValidationError when the IR's columns have save-blocking trait + * conflicts, so conflicts reject server-side and not only in the editor. A + * v1 IR (no columns) is a no-op. */ + private assertWorkflowIrTraitsValid(ir: WorkflowIr): void { + const columns = (ir as { columns?: WorkflowIrColumn[] }).columns; + if (Array.isArray(columns) && columns.length > 0) { + assertColumnTraitsValid(columns); + } + } + /** Create a named workflow definition. The IR is validated via parseWorkflowIr. */ async createWorkflowDefinition( input: WorkflowDefinitionInput, ): Promise { + // Rollback compat (#1405): with the flag OFF, persist a pure-v1-equivalent + // graph in the v1 shape so a binary downgrade can still load the row. + const flagOnForCreate = await this.workflowColumnsFlagOn(); return this.withConfigLock(async () => { const name = input.name?.trim(); if (!name) throw new Error("Workflow name is required"); // Validate the IR shape up front so we never persist a malformed graph. const ir = parseWorkflowIr(input.ir); + // Residual A: also reject save-blocking trait composition conflicts here, + // not only in the editor's client-side validation. + this.assertWorkflowIrTraitsValid(ir); const layout = input.layout ?? {}; const now = new Date().toISOString(); const id = this.nextWorkflowDefinitionId(); @@ -11250,7 +12271,9 @@ ${stepsSection}`; definition.id, definition.name, definition.description, - serializeWorkflowIr(definition.ir), + serializeWorkflowIr( + flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir), + ), JSON.stringify(definition.layout), definition.createdAt, definition.updatedAt, @@ -11305,13 +12328,102 @@ ${stepsSection}`; updates: WorkflowDefinitionUpdate, ): Promise { if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be edited"); - return this.withConfigLock(async () => { + // U5 (R20): flag-ON edits that remove an occupied column block with a typed + // OccupiedColumnsError unless `rehomeTo` is supplied. Computed before taking + // the config lock (pure DB reads) so the lock body stays focused. + const flagOn = await this.workflowColumnsFlagOn(); + let pendingRehome: { rehomeTo: string; occupantTaskIds: string[] } | undefined; + if (flagOn && updates.ir !== undefined) { + const existingForCheck = await this.getWorkflowDefinition(id); + if (!existingForCheck) throw new Error(`Workflow '${id}' not found`); + const nextIrForCheck = parseWorkflowIr(updates.ir); + const occupantsByColumn = this.occupantsByColumnForWorkflow(id, false); + const removed = computeRemovedOccupiedColumns( + existingForCheck.ir, + nextIrForCheck, + occupantsByColumn, + ); + if (removed.length > 0) { + if (updates.rehomeTo === undefined) { + throw new OccupiedColumnsError(id, removed); + } + assertRehomeTargetValid(nextIrForCheck, updates.rehomeTo); + // Collect the occupant task ids of the removed columns to re-home AFTER + // the IR save commits, so the cards land in a column the new IR defines. + const removedSet = new Set(removed.map((r) => r.columnId)); + const occupantTaskIds = this.listWorkflowOccupantTaskIds(id, false).filter((taskId) => { + const row = this.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as + | { column: string } + | undefined; + return row ? removedSet.has(row.column) : false; + }); + pendingRehome = { rehomeTo: updates.rehomeTo, occupantTaskIds }; + } + } + + // U11/KTD-13: when the IR changes custom field types incompatibly for tasks + // that already hold values, block with a typed IncompatibleFieldChangeError + // unless `coerce` is supplied. Removed/added fields never block (removal + // orphans). Flag-independent: fields are orthogonal to the columns flag. + // Reconciliation runs per occupant task AFTER the IR save commits. + let pendingFieldReconcile: + | { oldFields: WorkflowFieldDefinition[]; newFields: WorkflowFieldDefinition[]; occupantTaskIds: string[]; coerce?: "drop" | "keep-orphaned" } + | undefined; + if (updates.ir !== undefined) { + const existingForFields = await this.getWorkflowDefinition(id); + if (!existingForFields) throw new Error(`Workflow '${id}' not found`); + const nextIrForFields = parseWorkflowIr(updates.ir); + const oldFields: WorkflowFieldDefinition[] = + existingForFields.ir.version === "v2" ? (existingForFields.ir.fields ?? []) : []; + const newFields: WorkflowFieldDefinition[] = + nextIrForFields.version === "v2" ? (nextIrForFields.fields ?? []) : []; + const fieldsChanged = + JSON.stringify(oldFields) !== JSON.stringify(newFields); + if (fieldsChanged) { + const occupantTaskIds = this.listWorkflowOccupantTaskIds(id, false); + const occupantsByField = new Map(); + for (const taskId of occupantTaskIds) { + const row = this.db.prepare("SELECT customFields FROM tasks WHERE id = ?").get(taskId) as + | { customFields: string | null } + | undefined; + const values = row?.customFields + ? (fromJson>(row.customFields) ?? {}) + : {}; + // Incompatible-change detection only blocks on occupants that already + // HOLD a value for a field, so count only those. Reconciliation itself + // must still touch every occupant so new required+default fields get + // backfilled onto tasks that currently have no custom field values. + if (Object.keys(values).length === 0) continue; + for (const key of Object.keys(values)) { + occupantsByField.set(key, (occupantsByField.get(key) ?? 0) + 1); + } + } + const incompatible = computeIncompatibleFieldChanges( + existingForFields.ir, + nextIrForFields, + occupantsByField, + ); + if (incompatible.length > 0 && updates.coerce === undefined) { + throw new IncompatibleFieldChangeError(id, incompatible); + } + pendingFieldReconcile = { + oldFields, + newFields, + occupantTaskIds, + coerce: updates.coerce, + }; + } + } + const saved = await this.withConfigLock(async () => { const existing = await this.getWorkflowDefinition(id); if (!existing) throw new Error(`Workflow '${id}' not found`); const name = updates.name !== undefined ? updates.name.trim() : existing.name; if (!name) throw new Error("Workflow name is required"); const ir = updates.ir !== undefined ? parseWorkflowIr(updates.ir) : existing.ir; + // Residual A: reject save-blocking trait composition conflicts server-side + // when the IR is being changed. + if (updates.ir !== undefined) this.assertWorkflowIrTraitsValid(ir); const next: WorkflowDefinition = { ...existing, name, @@ -11328,7 +12440,8 @@ ${stepsSection}`; .run( next.name, next.description, - serializeWorkflowIr(next.ir), + // Rollback compat (#1405): persist v1 shape when pure and flag OFF. + serializeWorkflowIr(flagOn ? next.ir : downgradeIrToV1IfPure(next.ir)), JSON.stringify(next.layout), next.updatedAt, id, @@ -11338,6 +12451,35 @@ ${stepsSection}`; this.db.bumpLastModified(); return next; }); + + // U5 (R20): now that the new IR is committed, re-home the occupants of the + // removed columns into `rehomeTo` (one audit event per card). Done outside + // the config lock; each rehome takes its own task lock via moveTask. + if (pendingRehome) { + for (const taskId of pendingRehome.occupantTaskIds) { + await this.rehomeOccupant(taskId, pendingRehome.rehomeTo, "workflow-edit-rehome", { + workflowId: id, + }); + } + } + + // U11/KTD-13: now that the new field schema is committed, reconcile each + // occupant task's stored values against it (orphan-not-delete by default; + // coerce:"drop" discards orphans). Each runs under its own task lock. + if (pendingFieldReconcile) { + const dropOrphans = pendingFieldReconcile.coerce === "drop"; + for (const taskId of pendingFieldReconcile.occupantTaskIds) { + await this.withTaskLock(taskId, () => + this.reconcileTaskCustomFieldsForSchema( + taskId, + pendingFieldReconcile!.oldFields, + pendingFieldReconcile!.newFields, + dropOrphans, + ), + ); + } + } + return saved; } /** Delete a workflow definition, cascading to per-task selections, their @@ -11345,6 +12487,11 @@ ${stepsSection}`; * not exist. */ async deleteWorkflowDefinition(id: string): Promise { if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be deleted"); + // U5 (R20): flag-ON, capture the occupant task ids BEFORE the cascade clears + // their selection rows, so we can re-home them to the DEFAULT workflow's + // entry column once their selection resolves back to the default (KTD-1). + const flagOn = await this.workflowColumnsFlagOn(); + const occupantTaskIds = flagOn ? this.listWorkflowOccupantTaskIds(id, false) : []; const deleted = this.db.prepare("DELETE FROM workflows WHERE id = ?").run(id) as { changes?: number }; if ((deleted.changes || 0) === 0) { throw new Error(`Workflow '${id}' not found`); @@ -11388,6 +12535,409 @@ ${stepsSection}`; } if (selections.length > 0) this.workflowStepsCache = null; this.db.bumpLastModified(); + + // U5 (R20) delete reconciliation: re-home each occupant to the default + // workflow's entry column. Their selection rows are already cleared above, + // so they now resolve to the built-in default workflow (KTD-1); the re-home + // move preserves task fields (preserveProgress) and emits one audit per card. + if (flagOn && occupantTaskIds.length > 0) { + const defaultEntry = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR); + if (defaultEntry) { + for (const taskId of occupantTaskIds) { + await this.rehomeOccupant(taskId, defaultEntry, "workflow-delete", { workflowId: id }); + } + } + } + } + + // ── U5: workflow lifecycle reconciliation (switch / edit / delete) ────────── + // + // These helpers are only consulted when the `workflowColumns` flag is ON; the + // flag-OFF CRUD paths above keep their exact current behavior. Re-homing moves + // always route through `moveTask` with `moveSource: "engine"` + `bypassGuards` + // (a recovery-class move, KTD-9) — never a raw column write — so capacity + // (KTD-10) and the single transition authority (KTD-3) are honored. + + /** True when the `workflowColumns` flag is ON (merged global + project). */ + private async workflowColumnsFlagOn(): Promise { + return isWorkflowColumnsEnabled(await this.getSettingsFast()); + } + + /** The active (non-deleted) task ids currently selecting `workflowId`. A + * built-in/default workflow additionally owns every task with NO selection + * row (null selection resolves to the default workflow, KTD-1). */ + private listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): string[] { + const ids: string[] = []; + const selected = this.db + .prepare( + `SELECT s.taskId AS taskId FROM task_workflow_selection s + JOIN tasks t ON t.id = s.taskId + WHERE s.workflowId = ? AND t."deletedAt" IS NULL`, + ) + .all(workflowId) as Array<{ taskId: string }>; + for (const row of selected) ids.push(row.taskId); + if (includeNullSelection) { + const unselected = this.db + .prepare( + `SELECT t.id AS id FROM tasks t + WHERE t."deletedAt" IS NULL + AND NOT EXISTS (SELECT 1 FROM task_workflow_selection s WHERE s.taskId = t.id)`, + ) + .all() as Array<{ id: string }>; + for (const row of unselected) ids.push(row.id); + } + return ids; + } + + /** Map column id → occupant count for the tasks selecting `workflowId` + * (plus null-selection tasks when `includeNullSelection`). */ + private occupantsByColumnForWorkflow( + workflowId: string, + includeNullSelection: boolean, + ): Map { + const counts = new Map(); + for (const taskId of this.listWorkflowOccupantTaskIds(workflowId, includeNullSelection)) { + const row = this.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as + | { column: string } + | undefined; + if (!row) continue; + counts.set(row.column, (counts.get(row.column) ?? 0) + 1); + } + return counts; + } + + /** Re-home a single occupant to `targetColumn` via an engine-sourced, + * guard-bypassing recovery move, aborting in-flight work first, and emit one + * audit event. Best-effort per card: a failure is audited and skipped so one + * stuck card never blocks the rest of the batch. */ + private async rehomeOccupant( + taskId: string, + targetColumn: string, + reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome", + metadata: Record, + ): Promise { + const current = this.readTaskFromDb(taskId, { includeDeleted: false }); + if (!current) return; + const fromColumn = current.column; + if (fromColumn === targetColumn) { + // Already in the target column — nothing to move, but still record the + // reconciliation decision for audit traceability. + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `workflow-reconcile-${reason}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "task:workflow-reconcile", + target: taskId, + metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, moved: false }, + }); + return; + } + const abortRan = await runReconciliationAbort({ taskId, fromColumn, reason }); + let moved = false; + let error: string | undefined; + try { + // Recovery-class move: engine source + bypassGuards (KTD-9). preserveProgress + // keeps the task's fields intact (R20 delete semantics). Capacity (KTD-10) is + // NOT bypassed — a full target column rejects, which we audit and skip. + await this.moveTask(taskId, targetColumn, { + moveSource: "engine", + bypassGuards: true, + recoveryRehome: true, + preserveProgress: true, + preserveResumeState: true, + preserveWorktree: true, + allowDirectInReviewMove: true, + }); + moved = true; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `workflow-reconcile-${reason}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "task:workflow-reconcile", + target: taskId, + metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, abortRan, moved, error }, + }); + } + + // ── U12: workflow-columns integrity pass ────────────────────────────────── + // + // Migration rewrites ZERO task rows (KTD-1): a null selection resolves to the + // built-in default workflow at read time, and the default workflow's column + // IDs are byte-identical to the legacy enum values, so every legacy row is + // already valid. The only residual risk is a task whose stored column is not a + // valid column in its RESOLVED workflow — e.g. a custom workflow was edited to + // drop a column out-of-band, or a legacy row references a column the selected + // custom workflow never defined. The integrity pass audits those and re-homes + // them via the U5 reconciliation path (`recoveryRehome`, guard-bypassing, + // capacity-honoring), one audit event per card. + // + // Idempotent: a second run finds nothing out-of-place (the re-home lands the + // card in a valid column) and is a pure no-op. Tasks in complete- or + // archived-flagged columns are left UNTOUCHED (done/archived cards are terminal + // — re-homing them would corrupt the board) even if (defensively) their column + // were somehow not in the resolved IR; we never disturb terminal cards. + // + // Runs only when the `workflowColumns` flag is ON (flag-OFF keeps the legacy + // enum path, where every column is valid by construction). + async runWorkflowColumnsIntegrityPass(): Promise<{ scanned: number; rehomed: number; skippedTerminal: number }> { + let scanned = 0; + let rehomed = 0; + let skippedTerminal = 0; + + const rows = this.db + .prepare(`SELECT id FROM tasks WHERE "deletedAt" IS NULL`) + .all() as Array<{ id: string }>; + + const registry = getTraitRegistry(); + + for (const { id } of rows) { + scanned += 1; + const task = this.readTaskFromDb(id, { includeDeleted: false }); + if (!task) continue; + const ir = this.resolveTaskWorkflowIrSync(id); + const currentColumn = task.column; + + // Already valid in its resolved workflow — nothing to do (the common case; + // this is why the pass is idempotent and a no-op for healthy DBs). + if (workflowHasColumn(ir, currentColumn)) continue; + + // The stored column is not in the resolved workflow. Before re-homing, + // never disturb a terminal card: if the column the card sits in carries a + // complete/archived flag in its workflow it is terminal — but since the + // column is NOT in the IR we cannot read its flags there. Fall back to the + // legacy terminal semantics (done/archived) so terminal cards are never + // re-homed, matching the plan's "done/archived untouched" rule. + const column = findWorkflowColumn(ir, currentColumn); + const flags = column ? registry.resolveColumnFlags(column) : undefined; + const isTerminal = + flags?.complete === true || + flags?.archived === true || + currentColumn === "done" || + currentColumn === "archived"; + if (isTerminal) { + skippedTerminal += 1; + continue; + } + + const targetColumn = resolveEntryColumnId(ir); + if (!targetColumn) continue; // non-reconcilable IR — leave the card put. + + await this.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", { + integrityPass: true, + invalidColumn: currentColumn, + }); + rehomed += 1; + } + + if (rehomed > 0 || skippedTerminal > 0) { + storeLog.log("workflowColumns integrity pass completed", { + phase: "init:workflow-columns-integrity", + scanned, + rehomed, + skippedTerminal, + }); + } + return { scanned, rehomed, skippedTerminal }; + } + + // ── #1401: transitionPending recovery sweep ─────────────────────────────── + // + // A crash between the in-txn `transitionPending` marker write and the + // post-commit `clearTransitionPending` leaves the marker set forever. Because + // `countActiveInCapacitySlotSync` counts a pending marker as occupying a + // capacity slot for its `toColumn`, a stale marker permanently inflates that + // (workflow, column) capacity count. This sweep is the backstop the comments + // across store.ts / merge-trait.ts / transition-pending.ts reference: it scans + // every task carrying a non-null marker, reconciles `hooksRemaining` against + // the currently-known hook set, re-runs the surviving idempotent post-commit + // hooks via the same runner the live path uses, audits the recovery, and + // clears the marker so the reserved capacity slot is released. + // + // Idempotent: the default-workflow field effects already committed in-lock, so + // re-running them is a no-op, and a second sweep finds no markers. Plugin hooks + // are re-derived from the resolved IR (so an uninstalled-plugin hook simply + // drops, surfaced as an audit warning) and are expected to be idempotent per + // KTD-2. Runs at store init (alongside the integrity pass) and periodically + // from the flag-ON sweep cadence. + async recoverStaleTransitionPending(): Promise<{ scanned: number; recovered: number; degradedHooks: number }> { + let scanned = 0; + let recovered = 0; + let degradedHooks = 0; + + const rows = this.db + .prepare( + `SELECT id FROM tasks WHERE transitionPending IS NOT NULL AND transitionPending != '' AND deletedAt IS NULL`, + ) + .all() as Array<{ id: string }>; + + // The set of hook ids the current process can still honor: the always-present + // default-workflow post-commit marker plus every registered plugin trait's + // onEnter/onExit hook. A marker entry not in this set belongs to an + // uninstalled plugin and is dropped (audited) rather than re-run. + const registry = getTraitRegistry(); + const knownHookIds = new Set(["default-workflow:postCommit"]); + for (const def of registry.listTraits()) { + if (def.hooks?.onEnter) knownHookIds.add(`${def.id}:onEnter`); + if (def.hooks?.onExit) knownHookIds.add(`${def.id}:onExit`); + } + + for (const { id } of rows) { + scanned += 1; + const marker = readTransitionPending(this.db, id); + // null = nothing pending (corrupt/empty marker degrades to settled); we + // still clear the stored column so the slot is released. undefined = row + // vanished mid-sweep — skip. + if (marker === undefined) continue; + + await this.withTaskLock(id, async () => { + // Re-read inside the lock: another path may have cleared it already. + const live = readTransitionPending(this.db, id); + if (live == null) { + // Corrupt/empty marker — clear the stored value defensively so it stops + // counting against capacity, then move on. + if (live === null) { + try { + clearTransitionPending(this.db, id); + } catch { + // best-effort + } + } + return; + } + + const { hooksRemaining, warnings } = reconcileHooksRemaining(live.hooksRemaining, knownHookIds); + degradedHooks += warnings.length; + + // Re-run the surviving idempotent post-commit hooks. The default-workflow + // field effects already committed in-lock pre-crash, so the only work that + // can still be owed is the plugin trait hook runner, which re-derives its + // pending set from the resolved IR and is idempotent (KTD-2). We invoke it + // only when a plugin hook entry survived (a marker carrying just + // `default-workflow:postCommit` needs no re-run — just a clear). + const hasSurvivingPluginHook = hooksRemaining.some((h) => h !== "default-workflow:postCommit"); + if (hasSurvivingPluginHook) { + const task = this.readTaskFromDb(id, { includeDeleted: false }); + if (task) { + const ir = this.resolveTaskWorkflowIrSync(id); + // fromColumn is unknown post-crash; the marker only records toColumn. + // The hook runner keys onEnter off toColumn (and onExit off fromColumn); + // re-running onEnter for the destination is the recoverable, idempotent + // half. Use the task's current column as fromColumn (it committed to + // toColumn at marker-write time, so current == toColumn and onExit is a + // no-op, which is correct — we never re-fire an exit we may have run). + try { + await this.runPluginColumnTransitionHooks(id, ir, task.column, live.toColumn); + } catch (err) { + storeLog.warn("transitionPending recovery: hook re-run faulted (degraded)", { + phase: "recover-stale-transition-pending", + taskId: id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + for (const warning of warnings) { + storeLog.warn(warning, { + phase: "recover-stale-transition-pending", + taskId: id, + }); + } + + // Clear the marker — releases the reserved capacity slot. + try { + clearTransitionPending(this.db, id); + } catch { + // best-effort; a later sweep retries. + } + + this.recordRunAuditEvent({ + taskId: id, + agentId: "system", + runId: `transition-pending-recovery-${id}-${Date.now()}`, + domain: "database", + mutationType: "task:transition-pending-recovered", + target: id, + metadata: { + toColumn: live.toColumn, + hooksReran: hooksRemaining, + droppedHooks: warnings.length, + startedAt: live.startedAt, + }, + }); + recovered += 1; + }); + } + + if (recovered > 0 || degradedHooks > 0) { + storeLog.log("transitionPending recovery sweep completed", { + phase: "recover-stale-transition-pending", + scanned, + recovered, + degradedHooks, + }); + } + return { scanned, recovered, degradedHooks }; + } + + // ── #1409: flag ON→OFF evacuation ───────────────────────────────────────── + // + // When `workflowColumns` is disabled (or at flag-OFF store init), the board + // reverts to the legacy enum/`VALID_TRANSITIONS` path, where only the legacy + // {@link COLUMNS} are valid. Any card sitting in a CUSTOM (non-legacy) column + // would be stuck: it can't be listed/moved through the legacy path. This pass + // detects those cards and re-homes each to the nearest legacy column — the + // default workflow's entry column (`todo`) — via the existing recovery-rehome + // path (engine source + bypassGuards + recoveryRehome, capacity-honoring), + // auditing one event per card. Terminal cards (done/archived) are left put. + // + // Idempotent: a second run finds every card in a legacy column and is a no-op. + async evacuateCustomColumnsToLegacy( + trigger: "flag-off-init" | "flag-toggled-off", + ): Promise<{ scanned: number; evacuated: number }> { + let scanned = 0; + let evacuated = 0; + + const legacyColumns = new Set(COLUMNS); + // Nearest legacy landing column: the default workflow's entry column + // (triage). Falls back to "triage" defensively if the IR can't be resolved. + const targetColumn = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR) ?? "triage"; + + const rows = this.db + .prepare(`SELECT id, "column" AS col FROM tasks WHERE deletedAt IS NULL`) + .all() as Array<{ id: string; col: string }>; + + for (const { id, col } of rows) { + scanned += 1; + // Already in a legacy column (the common case) — nothing to evacuate. + if (legacyColumns.has(col)) continue; + // Never disturb terminal cards (legacy terminal semantics — these column + // ids are never legacy here, but guard defensively for parity with the + // integrity pass). + if (col === "done" || col === "archived") continue; + + await this.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", { + evacuation: true, + trigger, + invalidColumn: col, + }); + evacuated += 1; + } + + if (evacuated > 0) { + storeLog.log("workflowColumns ON→OFF evacuation completed", { + phase: "evacuate-custom-columns", + trigger, + scanned, + evacuated, + }); + } + return { scanned, evacuated }; } // ── Workflow selection (resolves a workflow to enabledWorkflowSteps) ──── @@ -11436,6 +12986,160 @@ ${stepsSection}`; } /** Read the workflow currently selected for a task, if any. */ + /** + * Synchronously resolve the parsed WorkflowIr that governs a task's columns + * (U4, flag-ON path). Resolution order: + * 1. the task's workflow selection (side table) → that workflow's IR; + * 2. null/missing selection → the built-in default workflow IR (KTD-1). + * Built-in workflow IRs are resolved from the parsed module constant; custom + * workflows are read + parsed from the `workflows` row. Pure DB read, safe to + * call inside `withTaskLock` (no further locks taken). A parse failure or + * missing custom row falls back to the default workflow so a move is never + * stranded by a corrupt definition (degraded, not crashed). + */ + /** + * U8 (KTD-2): record a pre-evaluated plugin gate verdict for a move into + * `toColumn`. Called by the engine's plugin trait adapter AFTER it evaluated + * the gate (prompt/script) outside the task lock. The flag-ON guard site in + * `moveTaskInternal` re-checks the recorded verdict in-lock. Verdicts are + * consumed (cleared) by `consumePluginGateVerdicts` once read so a stale + * verdict can't silently re-authorize a later move. + */ + recordPluginGateVerdict( + taskId: string, + toColumn: string, + verdict: Omit & { recordedAt?: number }, + ): void { + let byColumn = this.pluginGateVerdicts.get(taskId); + if (!byColumn) { + byColumn = new Map(); + this.pluginGateVerdicts.set(taskId, byColumn); + } + const list = byColumn.get(toColumn) ?? []; + // Replace any prior verdict for the same trait (latest evaluation wins). + const filtered = list.filter((v) => v.traitId !== verdict.traitId); + filtered.push({ ...verdict, recordedAt: verdict.recordedAt ?? Date.now() }); + byColumn.set(toColumn, filtered); + } + + /** + * U8: read AND clear the recorded plugin gate verdicts for a (task, column). + * Returns the recorded verdicts (possibly empty). Consuming clears them so the + * verdict authorizes exactly one move attempt. + */ + consumePluginGateVerdicts(taskId: string, toColumn: string): PluginGateVerdict[] { + const byColumn = this.pluginGateVerdicts.get(taskId); + if (!byColumn) return []; + const list = byColumn.get(toColumn) ?? []; + byColumn.delete(toColumn); + if (byColumn.size === 0) this.pluginGateVerdicts.delete(taskId); + return list; + } + + /** + * Resolve the custom-field definitions (KTD-13) governing a task, via its + * workflow selection. v1 IR and the default workflow declare none → `[]`. + * Pure DB read, safe inside transactions. + */ + private resolveTaskCustomFieldDefsSync(taskId: string): WorkflowFieldDefinition[] { + const ir = this.resolveTaskWorkflowIrSync(taskId); + return ir.version === "v2" ? (ir.fields ?? []) : []; + } + + private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr { + const selection = this.getTaskWorkflowSelection(taskId); + const workflowId = selection?.workflowId; + if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR; + if (isBuiltinWorkflowId(workflowId)) { + const builtin = getBuiltinWorkflow(workflowId); + return builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + } + try { + const row = this.db + .prepare("SELECT ir FROM workflows WHERE id = ?") + .get(workflowId) as { ir: string } | undefined; + if (!row) return BUILTIN_CODING_WORKFLOW_IR; + return parseWorkflowIr(row.ir); + } catch { + return BUILTIN_CODING_WORKFLOW_IR; + } + } + + /** + * U6 (KTD-10): the *effective workflow id* used to scope the per-(workflow, + * column) capacity count. A task with no selection (or a missing/empty + * selection row) resolves to the built-in default workflow, represented by a + * stable sentinel so all default-workflow tasks share one capacity pool. A + * selected workflow id (builtin or custom) is its own pool. Pure DB read; safe + * inside the move transaction. + */ + private resolveEffectiveWorkflowIdSync(taskId: string): string { + const selection = this.getTaskWorkflowSelection(taskId); + return selection?.workflowId ?? TaskStore.DEFAULT_WORKFLOW_POOL_ID; + } + + /** + * U6 (KTD-10): count cards currently occupying a (workflow, column) capacity + * slot, for the in-txn capacity check. Runs INSIDE `moveTaskInternal`'s + * transaction. A slot is held by a card that: + * - has committed its column to `targetColumn` (the steady-state holders), OR + * - (when `countPending`) has a `transitionPending` marker targeting + * `targetColumn` — it reserved the slot at commit time even though its + * post-commit hooks haven't finished yet. + * The moving task itself (`excludeTaskId`) is excluded so a same-column no-op + * or re-entry never counts itself. Only the candidates in the SAME effective + * workflow as the mover count (capacity is per-(workflow, column)). Soft-deleted + * tasks never hold a slot. + */ + private countActiveInCapacitySlotSync(params: { + targetColumn: string; + workflowId: string; + countPending: boolean; + excludeTaskId: string; + }): number { + const { targetColumn, workflowId, countPending, excludeTaskId } = params; + // Candidate rows: in the column now, or (optionally) mid-transition into it. + // LEFT JOIN the selection row so we can scope by effective workflow id in JS. + const rows = this.db + .prepare( + `SELECT t.id AS id, t."column" AS col, t.transitionPending AS tp, s.workflowId AS wid + FROM tasks t + LEFT JOIN task_workflow_selection s ON s.taskId = t.id + WHERE t.deletedAt IS NULL + AND t.id != ? + AND (t."column" = ? OR (t.transitionPending IS NOT NULL AND t.transitionPending != ''))`, + ) + .all(excludeTaskId, targetColumn) as Array<{ + id: string; + col: string; + tp: string | null; + wid: string | null; + }>; + + let count = 0; + for (const row of rows) { + const effectiveWorkflowId = row.wid ?? TaskStore.DEFAULT_WORKFLOW_POOL_ID; + if (effectiveWorkflowId !== workflowId) continue; + + if (row.col === targetColumn) { + count += 1; + continue; + } + // Not committed into the column — only counts if it has reserved the slot + // via a transitionPending marker targeting this column AND countPending. + if (!countPending || !row.tp) continue; + let toColumn: string | undefined; + try { + const parsed = JSON.parse(row.tp) as { toColumn?: unknown }; + if (typeof parsed.toColumn === "string") toColumn = parsed.toColumn; + } catch { + // Corrupt marker — treat as not holding this slot. + } + if (toColumn === targetColumn) count += 1; + } + return count; + } + getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined { const row = this.db .prepare("SELECT workflowId, stepIds FROM task_workflow_selection WHERE taskId = ?") @@ -11574,6 +13278,12 @@ ${stepsSection}`; // prior selection's rows, so a mid-flight failure never leaves the task // referencing already-deleted step ids. const priorSelection = this.getTaskWorkflowSelection(taskId); + // U11/KTD-13: capture the OLD field schema (from the prior selection's IR) + // before the selection row flips, so we can reconcile existing field values + // against the NEW workflow's schema below. + const oldFieldDefs = this.resolveTaskCustomFieldDefsSync(taskId); + const newFieldDefs: WorkflowFieldDefinition[] = + def.ir.version === "v2" ? (def.ir.fields ?? []) : []; const ids = await this.materializeWorkflowSteps(workflowId, inputs); try { await this.updateTaskUnlocked(taskId, { enabledWorkflowSteps: ids }); @@ -11599,10 +13309,96 @@ ${stepsSection}`; } this.workflowStepsCache = null; } + + // U11/KTD-13: reconcile custom field values against the NEW workflow's + // schema. Same-id, type-compatible values are kept; incompatible/removed + // ids are orphaned — but RETAINED in storage (orphan-not-delete) so a later + // switch back, or the orphaned-fields disclosure, can still surface them. + // Then fill defaults for the new workflow's required+default fields that + // are absent. The merged object is written DIRECTLY (bypassing the + // validating patch path) because orphaned ids are by definition unknown to + // the new schema and would otherwise be rejected. + await this.reconcileTaskCustomFieldsForSchema(taskId, oldFieldDefs, newFieldDefs); + return ids; }); } + /** + * U11/KTD-13: reconcile a task's stored custom field values when its governing + * field schema changes (workflow switch or definition edit). Values are + * partitioned by {@link reconcileFieldsOnWorkflowChange}; orphans are retained + * (never destroyed). Required+default fields absent from the result are filled. + * Writes the merged values directly onto task.json — orphaned ids are unknown + * to the new schema, so this deliberately bypasses the validating patch path. + * Assumes the caller already holds the per-task lock. + */ + private async reconcileTaskCustomFieldsForSchema( + taskId: string, + oldFieldDefs: WorkflowFieldDefinition[], + newFieldDefs: WorkflowFieldDefinition[], + dropOrphans = false, + ): Promise { + const dir = this.taskDir(taskId); + const task = await this.readTaskJson(dir); + const current = task.customFields ?? {}; + const { kept, orphaned } = reconcileFieldsOnWorkflowChange(oldFieldDefs, newFieldDefs, current); + // Default (keep-orphaned): storage keeps everything (kept ∪ orphaned). + // coerce:"drop" discards the orphaned values entirely. + const base = dropOrphans ? { ...kept } : { ...kept, ...orphaned }; + const reconciled = applyFieldDefaults(newFieldDefs, base); + // Skip the write when nothing changed (no defaults added, same keys/values). + const unchanged = + Object.keys(reconciled).length === Object.keys(current).length && + Object.entries(reconciled).every(([k, v]) => current[k] === v); + if (unchanged) return; + task.customFields = reconciled; + task.updatedAt = new Date().toISOString(); + await this.atomicWriteTaskJson(dir, task); + if (this.isWatching) this.taskCache.set(taskId, { ...task }); + this.emitTaskLifecycleEventSafely("task:updated", [task]); + } + + /** + * U5 (R20) workflow switch: select a workflow for a task and, when the + * `workflowColumns` flag is ON, reconcile the card's board column against the + * NEW workflow. Same-id column preserves position; otherwise the card re-homes + * to the new workflow's entry (intake-flagged, else first) column, aborting + * in-flight processing first (KTD-9). Returns the materialized step ids plus + * the switch outcome so the dashboard can surface the re-home. + * + * Reconciliation runs AFTER `selectTaskWorkflow` releases the per-task lock + * (moveTask takes its own lock; the per-task lock is non-reentrant). + */ + async selectTaskWorkflowAndReconcile( + taskId: string, + workflowId: string, + ): Promise<{ + enabledWorkflowSteps: string[]; + reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string }; + }> { + const enabledWorkflowSteps = await this.selectTaskWorkflow(taskId, workflowId); + if (!(await this.workflowColumnsFlagOn())) { + return { enabledWorkflowSteps }; + } + const newIr = this.resolveTaskWorkflowIrSync(taskId); + const current = this.readTaskFromDb(taskId, { includeDeleted: false }); + if (!current) return { enabledWorkflowSteps }; + const fromColumn = current.column; + const decision = resolveSwitchReconciliation(newIr, fromColumn); + if (!decision.preserved && decision.targetColumn !== fromColumn) { + await this.rehomeOccupant(taskId, decision.targetColumn, "workflow-switch", { workflowId }); + } + return { + enabledWorkflowSteps, + reconciliation: { + preserved: decision.preserved, + fromColumn, + toColumn: decision.targetColumn, + }, + }; + } + /** Clear a task's workflow selection and its enabled steps. */ async clearTaskWorkflowSelection(taskId: string): Promise { await this.withTaskLock(taskId, async () => { diff --git a/packages/core/src/task-age-staleness.ts b/packages/core/src/task-age-staleness.ts index b55ed779f4..f4bf79c5dd 100644 --- a/packages/core/src/task-age-staleness.ts +++ b/packages/core/src/task-age-staleness.ts @@ -50,6 +50,10 @@ export function getTaskAgeStalenessSignal( if (task.column !== "in-progress" && task.column !== "in-review") { return undefined; } + // The guard above proves `column` is one of these two legacy ids; the + // `ColumnId` union's `string & {}` member can't be excluded by literal `!==` + // narrowing, so the cast is provably safe here (#1403). + const activeColumn = task.column as "in-progress" | "in-review"; if (task.mergeDetails?.mergeConfirmed === true) { return undefined; } @@ -105,7 +109,7 @@ export function getTaskAgeStalenessSignal( ageMs, warningThresholdMs: warningThresholdMs ?? 0, criticalThresholdMs: criticalThresholdMs ?? 0, - column: task.column, + column: activeColumn, paused: task.paused === true, }; } diff --git a/packages/core/src/task-fields.ts b/packages/core/src/task-fields.ts new file mode 100644 index 0000000000..93fc8a0a9b --- /dev/null +++ b/packages/core/src/task-fields.ts @@ -0,0 +1,355 @@ +/** + * Custom task field validation & reconciliation authority (U11 / KTD-13). + * + * Workflows declare typed custom task fields ({@link WorkflowFieldDefinition}); + * task values live in `tasks.customFields` (a JSON object keyed by field id). + * This module is the single, side-effect-free validation core that the store + * write authority (`updateTaskCustomFields` / `updateTask`) delegates to. It + * mirrors the `TransitionRejection` style: a flat, JSON-safe typed rejection + * with a machine-stable `code`, the offending `fieldId`, and a non-localized + * `detail` string for audit/logs. + * + * Three operations: + * - {@link validateCustomFieldPatch} — validate a `Record` + * patch against a field schema, normalizing accepted values. `null`/`undefined` + * in the patch is a delete sentinel for that field (always accepted). + * - {@link applyFieldDefaults} — fill `default` for required fields absent from + * the current values (task create / workflow selection). + * - {@link reconcileFieldsOnWorkflowChange} — partition existing values into + * `kept` (same id, type-compatible) and `orphaned` (everything else) when a + * workflow's fields change or the task switches workflows. Orphans are + * RETAINED in storage — this only computes the partition so the UI can render + * the orphaned-fields disclosure. + */ + +import type { + WorkflowFieldDefinition, +} from "./workflow-ir-types.js"; + +// --------------------------------------------------------------------------- +// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class) +// --------------------------------------------------------------------------- + +/** + * Reason codes for a rejected custom-field write. Stable string literals — they + * cross the agent-tool / HTTP boundary and are matched by surfaces for copy, so + * they must not change without migrating consumers. + */ +export type CustomFieldRejectionCode = + | "no-fields-defined" + | "unknown-field" + | "type-mismatch" + | "enum-violation"; + +/** The full, immutable set of custom-field rejection codes. */ +export const CUSTOM_FIELD_REJECTION_CODES: readonly CustomFieldRejectionCode[] = [ + "no-fields-defined", + "unknown-field", + "type-mismatch", + "enum-violation", +] as const; + +/** + * A typed custom-field rejection. Flat and JSON-safe by construction — mirrors + * {@link import("./transition-types.js").TransitionRejection}. + * + * - `code` — machine-stable {@link CustomFieldRejectionCode}. + * - `fieldId` — the offending field id (the patch key that failed). + * - `detail` — non-localized diagnostic context for audit/logs. + */ +export interface CustomFieldRejection { + code: CustomFieldRejectionCode; + fieldId: string; + detail: string; +} + +/** Result of validating a custom-field patch. Discriminated on `ok`. */ +export type CustomFieldPatchResult = + | { ok: true; normalized: Record } + | { ok: false; rejection: CustomFieldRejection }; + +/** Construct a {@link CustomFieldRejection}. */ +export function makeCustomFieldRejection( + code: CustomFieldRejectionCode, + fieldId: string, + detail: string, +): CustomFieldRejection { + return { code, fieldId, detail }; +} + +/** + * Thrown by the throw-based write paths (`updateTask` with a `customFields` + * patch) when validation rejects. `updateTaskCustomFields` returns the typed + * rejection instead; this wrapper exists for the legacy throw contract so a bad + * `updateTask` write fails loudly rather than silently round-tripping an invalid + * value (the U4 opaque behavior). Carries the structured rejection so HTTP/agent + * surfaces can recover the field path and code. + */ +export class CustomFieldRejectionError extends Error { + readonly rejection: CustomFieldRejection; + constructor(rejection: CustomFieldRejection) { + super(`custom field '${rejection.fieldId}' rejected (${rejection.code}): ${rejection.detail}`); + this.name = "CustomFieldRejectionError"; + this.rejection = rejection; + } +} + +// --------------------------------------------------------------------------- +// Per-type value validation +// --------------------------------------------------------------------------- + +/** True iff `value` is a non-empty option-value member of `field.options`. */ +function isEnumMember(field: WorkflowFieldDefinition, value: string): boolean { + return (field.options ?? []).some((o) => o.value === value); +} + +/** + * Validate (and normalize) a single non-null value against a field's type. + * Returns the normalized value on success, or a rejection. The caller has + * already resolved the field definition. + */ +function validateValue( + field: WorkflowFieldDefinition, + value: unknown, +): { ok: true; value: unknown } | { ok: false; rejection: CustomFieldRejection } { + const reject = ( + code: CustomFieldRejectionCode, + detail: string, + ): { ok: false; rejection: CustomFieldRejection } => ({ + ok: false, + rejection: makeCustomFieldRejection(code, field.id, detail), + }); + + switch (field.type) { + case "string": + case "text": { + if (typeof value !== "string") { + return reject("type-mismatch", `field '${field.id}' expects a string, got ${typeof value}`); + } + return { ok: true, value }; + } + case "number": { + if (typeof value !== "number" || !Number.isFinite(value)) { + return reject( + "type-mismatch", + `field '${field.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`, + ); + } + return { ok: true, value }; + } + case "boolean": { + if (typeof value !== "boolean") { + return reject("type-mismatch", `field '${field.id}' expects a boolean, got ${typeof value}`); + } + return { ok: true, value }; + } + case "enum": { + if (typeof value !== "string") { + return reject("type-mismatch", `field '${field.id}' (enum) expects a string option value, got ${typeof value}`); + } + if (!isEnumMember(field, value)) { + return reject("enum-violation", `field '${field.id}' value '${value}' is not a declared option`); + } + return { ok: true, value }; + } + case "multi-enum": { + if (!Array.isArray(value)) { + return reject("type-mismatch", `field '${field.id}' (multi-enum) expects an array, got ${typeof value}`); + } + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") { + return reject("type-mismatch", `field '${field.id}' (multi-enum) members must be strings`); + } + if (!isEnumMember(field, item)) { + return reject("enum-violation", `field '${field.id}' member '${item}' is not a declared option`); + } + if (seen.has(item)) { + return reject("enum-violation", `field '${field.id}' has duplicate member '${item}'`); + } + seen.add(item); + } + return { ok: true, value: [...value] as string[] }; + } + case "date": { + if (typeof value !== "string") { + return reject("type-mismatch", `field '${field.id}' (date) expects an ISO date string, got ${typeof value}`); + } + const ms = Date.parse(value); + if (Number.isNaN(ms)) { + return reject("type-mismatch", `field '${field.id}' value '${value}' is not a parseable date`); + } + return { ok: true, value }; + } + case "url": { + if (typeof value !== "string") { + return reject("type-mismatch", `field '${field.id}' (url) expects a string, got ${typeof value}`); + } + try { + new URL(value); + } catch { + return reject("type-mismatch", `field '${field.id}' value '${value}' is not a valid URL`); + } + return { ok: true, value }; + } + default: { + // Exhaustiveness guard — an unknown type cannot validate. + const _exhaustive: never = field.type; + return reject("type-mismatch", `field '${field.id}' has unsupported type '${String(_exhaustive)}'`); + } + } +} + +// --------------------------------------------------------------------------- +// Patch validation authority +// --------------------------------------------------------------------------- + +/** + * Validate a custom-field `patch` against a workflow's field `fields`. + * + * - A `null`/`undefined` patch value is a DELETE sentinel: the field's stored + * value should be removed. It is always accepted (even for required fields — + * required is not a write-time gate this round, KTD-13) and surfaces in + * `normalized` as `null` so the caller can apply the delete uniformly. + * - A non-null value is validated/normalized per the field's type. + * - A patch key that names no declared field → `unknown-field`. + * - When `fields` is undefined/empty and the patch carries any key → the whole + * patch is rejected `no-fields-defined` (the default workflow declares no + * fields; nothing can be written). An empty patch against no fields is `ok`. + * + * Validation is fail-fast: the first offending key produces the rejection. + */ +export function validateCustomFieldPatch( + fields: WorkflowFieldDefinition[] | undefined, + patch: Record, +): CustomFieldPatchResult { + const keys = Object.keys(patch); + const byId = new Map((fields ?? []).map((f) => [f.id, f])); + + if (byId.size === 0) { + if (keys.length === 0) return { ok: true, normalized: {} }; + return { + ok: false, + rejection: makeCustomFieldRejection( + "no-fields-defined", + keys[0]!, + "the resolved workflow declares no custom fields; no values may be written", + ), + }; + } + + const normalized: Record = {}; + for (const key of keys) { + const value = patch[key]; + const field = byId.get(key); + if (!field) { + return { + ok: false, + rejection: makeCustomFieldRejection( + "unknown-field", + key, + `field '${key}' is not declared by the task's workflow`, + ), + }; + } + // null/undefined = delete this field's value. + if (value === null || value === undefined) { + normalized[key] = null; + continue; + } + const res = validateValue(field, value); + if (!res.ok) return res; + normalized[key] = res.value; + } + return { ok: true, normalized }; +} + +// --------------------------------------------------------------------------- +// Defaults at create / workflow selection +// --------------------------------------------------------------------------- + +/** + * Fill `default` values for REQUIRED fields that are absent from `current`. + * Returns a NEW merged object (does not mutate `current`); existing values win. + * Non-required fields and fields without a declared `default` are left absent. + * + * Used at task create / workflow selection so a workflow with required+default + * fields lands sensible initial values. Defaults are taken on trust from the + * (already-validated-at-save) field schema. + */ +export function applyFieldDefaults( + fields: WorkflowFieldDefinition[] | undefined, + current: Record | undefined, +): Record { + const out: Record = { ...(current ?? {}) }; + for (const field of fields ?? []) { + if (!field.required) continue; + if (field.default === undefined) continue; + if (Object.prototype.hasOwnProperty.call(out, field.id) && out[field.id] !== undefined) { + continue; + } + out[field.id] = field.default; + } + return out; +} + +// --------------------------------------------------------------------------- +// Reconciliation on workflow edit / switch +// --------------------------------------------------------------------------- + +/** + * A stored value for `field` is type-compatible with a new field definition iff + * the new value re-validates cleanly. For enum-kind fields, compatibility also + * requires the value still be a member of the new options (handled by + * re-validation). This is the same gate {@link validateValue} applies on write, + * so "kept" values are guaranteed re-writable under the new schema. + */ +function valueCompatible(newField: WorkflowFieldDefinition, value: unknown): boolean { + if (value === null || value === undefined) return true; + return validateValue(newField, value).ok; +} + +/** Partition of existing values produced by {@link reconcileFieldsOnWorkflowChange}. */ +export interface FieldReconciliation { + /** Values whose id survives in the new schema AND remain type-compatible. */ + kept: Record; + /** + * Values that no longer fit: id removed from the new schema, or the type + * changed incompatibly (including an enum value no longer in the new options). + * RETAINED in storage — listed here only so the UI can render them under the + * orphaned-fields disclosure. + */ + orphaned: Record; +} + +/** + * Reconcile stored `values` when a workflow's field schema changes (edit) or a + * task switches workflows. Same-id values are KEPT when the new field is + * type-compatible (same type, or both enum-kind with the value still a member — + * enforced by re-validation); everything else is ORPHANED. + * + * Storage keeps EVERYTHING — this function only computes the partition. Callers + * persist `{...kept, ...orphaned}` (i.e. the original values, unchanged) and use + * `orphaned` purely for UI disclosure. `oldFields` is accepted for symmetry and + * future heuristics; the decision is driven entirely by `newFields` + the value. + */ +export function reconcileFieldsOnWorkflowChange( + oldFields: WorkflowFieldDefinition[] | undefined, + newFields: WorkflowFieldDefinition[] | undefined, + values: Record | undefined, +): FieldReconciliation { + void oldFields; // reserved for future migration heuristics; intentionally unused + const newById = new Map((newFields ?? []).map((f) => [f.id, f])); + const kept: Record = {}; + const orphaned: Record = {}; + + for (const [id, value] of Object.entries(values ?? {})) { + const newField = newById.get(id); + if (newField && valueCompatible(newField, value)) { + kept[id] = value; + } else { + orphaned[id] = value; + } + } + return { kept, orphaned }; +} diff --git a/packages/core/src/trait-registry.ts b/packages/core/src/trait-registry.ts new file mode 100644 index 0000000000..382339b058 --- /dev/null +++ b/packages/core/src/trait-registry.ts @@ -0,0 +1,432 @@ +/** + * Trait registry (U2, R6/R8/R22). + * + * One registry resolving trait ids to definitions (flags + hook descriptors) + * for both built-ins and (later) plugins. Provides: + * - registration with `builtin:`-style namespace protection and + * restricted-capability enforcement (R22); + * - hook-implementation DI (engine registers impls; unregistered hooks + * resolve to a no-op + audit warning — degraded, not crashed); + * - effective-flag resolution for a column's trait set; + * - the save-time / load-time composition validator returning typed + * violations with named reason codes, distinguishing `error` + * (save-blocked) from `degraded` (load-time advisory). + * + * Core stays engine-free: no `@fusion/engine` import. Hook implementations are + * wired in via `registerTraitHookImpl` (mirrors `setCreateFnAgent`). + */ + +import type { + TraitDefinition, + TraitFlags, + TraitHookImpl, + TraitHookKind, +} from "./trait-types.js"; +import { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js"; +import type { WorkflowIrColumn, WorkflowIrColumnTrait } from "./workflow-ir-types.js"; + +// ── Registration error ────────────────────────────────────────────────────── + +/** Named reason codes for a rejected trait registration. */ +export type TraitRegistrationReason = + | "duplicate-id" + | "builtin-namespace-protected" + | "restricted-flag" + | "restricted-guard-hook" + | "invalid-definition"; + +export class TraitRegistrationError extends Error { + readonly reason: TraitRegistrationReason; + readonly traitId: string; + constructor(reason: TraitRegistrationReason, traitId: string, message: string) { + super(message); + this.name = "TraitRegistrationError"; + this.reason = reason; + this.traitId = traitId; + } +} + +// ── Composition violation contract ────────────────────────────────────────── + +/** Named reason codes for a composition violation. */ +export type TraitViolationCode = + | "complete-with-wip" + | "two-capacity-traits" + | "complete-with-intake" + | "archived-with-wip" + | "multiple-intake-columns" + | "unknown-trait"; + +/** Severity: `error` blocks the save; `degraded` is a load-time advisory — the + * definition still loads (per U2's load-time re-validation requirement). */ +export type TraitViolationSeverity = "error" | "degraded"; + +export interface TraitViolation { + code: TraitViolationCode; + severity: TraitViolationSeverity; + /** Column id the violation applies to, or null for workflow-wide violations. */ + columnId: string | null; + /** The trait ids implicated (for actionable messaging). */ + traitIds: string[]; + message: string; +} + +/** + * Thrown by the store's create/update workflow paths (residual A) when a + * workflow's trait composition has `error`-severity violations under `save` + * mode — so trait conflicts reject server-side, not only in the editor. Carries + * the structured violations so the surface can render them per-column. The + * dashboard routes map this to a 400 (consistent with `WorkflowIrError`). + */ +export class ColumnTraitValidationError extends Error { + readonly violations: TraitViolation[]; + constructor(violations: TraitViolation[]) { + const summary = violations.map((v) => v.message).join("; "); + super(`Workflow trait composition invalid: ${summary}`); + this.name = "ColumnTraitValidationError"; + this.violations = violations; + } +} + +/** A simple audit-warning record returned by hook resolution / load-time + * re-validation. Modeled as a returned value (not a thrown error and not an + * engine logger) so core stays engine-free; callers may forward it to audit. */ +export interface TraitAuditWarning { + kind: "missing-hook-impl" | "degraded-composition"; + traitId?: string; + hookKind?: TraitHookKind; + message: string; +} + +// ── The registry ──────────────────────────────────────────────────────────── + +export class TraitRegistry { + private readonly traits = new Map(); + private readonly hookImpls = new Map(); + + /** Register a trait. Rejects duplicates, builtin-namespace overrides by + * non-builtins, and restricted-capability declarations by non-builtins (R22). */ + register(def: TraitDefinition): void { + if (!def.id || typeof def.id !== "string") { + throw new TraitRegistrationError( + "invalid-definition", + String(def.id), + "Trait definition must have a non-empty string id", + ); + } + + const existing = this.traits.get(def.id); + if (existing) { + // A built-in id (or any already-registered id) cannot be overridden. + if (!def.builtin && existing.builtin) { + throw new TraitRegistrationError( + "builtin-namespace-protected", + def.id, + `Trait id '${def.id}' is a built-in trait and cannot be overridden by a non-builtin registration`, + ); + } + throw new TraitRegistrationError( + "duplicate-id", + def.id, + `Trait id '${def.id}' is already registered`, + ); + } + + if (!def.builtin) { + // Non-builtin (plugin) traits cannot declare restricted flags (R22). + for (const flag of RESTRICTED_TRAIT_FLAGS) { + if (def.flags?.[flag]) { + throw new TraitRegistrationError( + "restricted-flag", + def.id, + `Non-builtin trait '${def.id}' may not declare the restricted flag '${flag}'`, + ); + } + } + // Non-builtin traits cannot declare the sync `guard` hook (KTD-2/R22). + if (def.hooks?.guard) { + throw new TraitRegistrationError( + "restricted-guard-hook", + def.id, + `Non-builtin trait '${def.id}' may not declare a sync 'guard' hook (built-in only)`, + ); + } + } + + this.traits.set(def.id, def); + } + + getTrait(id: string): TraitDefinition | undefined { + return this.traits.get(id); + } + + /** Catalog of all registered traits (for the dashboard endpoint, later). */ + listTraits(): TraitDefinition[] { + return [...this.traits.values()]; + } + + has(id: string): boolean { + return this.traits.has(id); + } + + // ── Hook implementation DI (engine wires impls in) ──────────────────────── + + /** Register a hook implementation for a (traitId, hookKind). Called by the + * engine (mirrors `setCreateFnAgent`); core never supplies impls. */ + registerTraitHookImpl(traitId: string, hookKind: TraitHookKind, impl: TraitHookImpl): void { + this.hookImpls.set(traitHookKey(traitId, hookKind), impl); + } + + /** + * Deregister a hook implementation for a (traitId, hookKind). After this, a + * trait that still DECLARES the hook resolves to a no-op + audit warning (the + * degraded path) rather than executing — this is exactly the "force-disable a + * plugin → columns degrade to passive" path (U8/KTD-7). Returns true if an + * impl was present and removed. + */ + deregisterTraitHookImpl(traitId: string, hookKind: TraitHookKind): boolean { + return this.hookImpls.delete(traitHookKey(traitId, hookKind)); + } + + /** + * Remove a trait definition entirely (e.g. when a plugin is fully + * unregistered with no live dependents). Also drops any registered hook impls + * for that trait. Returns true if the trait was present. Built-in traits are + * never removed by this (they are not plugin-owned); callers should only pass + * plugin-namespaced ids. + */ + unregisterTrait(traitId: string): boolean { + const def = this.traits.get(traitId); + if (!def || def.builtin) return false; + for (const hookKind of ["guard", "gate", "onEnter", "onExit", "releaseCondition"] as TraitHookKind[]) { + this.hookImpls.delete(traitHookKey(traitId, hookKind)); + } + return this.traits.delete(traitId); + } + + /** Resolve a hook implementation. If the trait declares the hook but no impl + * is registered, returns a no-op plus an audit warning (degraded, not + * crashed). Returns `{ impl: undefined }` with no warning if the trait does + * not declare the hook at all. */ + resolveTraitHook( + traitId: string, + hookKind: TraitHookKind, + ): { impl: TraitHookImpl | undefined; warning?: TraitAuditWarning } { + const def = this.traits.get(traitId); + const declared = Boolean(def?.hooks?.[hookKind]); + const impl = this.hookImpls.get(traitHookKey(traitId, hookKind)); + if (impl) return { impl }; + if (declared) { + const noop: TraitHookImpl = () => undefined; + return { + impl: noop, + warning: { + kind: "missing-hook-impl", + traitId, + hookKind, + message: `Trait '${traitId}' declares a '${hookKind}' hook but no implementation is registered; resolving to a no-op`, + }, + }; + } + return { impl: undefined }; + } + + // ── Flag resolution ─────────────────────────────────────────────────────── + + /** Merged effective flags of a column's traits (OR across booleans). Unknown + * trait ids are ignored here (validation surfaces them via + * validateColumnTraits). */ + resolveColumnFlags(column: WorkflowIrColumn): TraitFlags { + const merged: TraitFlags = {}; + for (const ct of column.traits) { + const def = this.traits.get(ct.trait); + if (!def) continue; + for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) { + if (value) merged[key] = true; + } + } + return merged; + } + + // ── Composition validation ──────────────────────────────────────────────── + + /** + * Validate a workflow's columns' trait composition. Returns typed violations + * with named reason codes. `mode: "save"` produces `error` severities that + * block the save; `mode: "load"` degrades the *unknown-trait* violation to an + * advisory so definitions predating a newly added trait still load (per U2's + * load-time re-validation requirement). Hard structural conflicts remain + * errors in both modes (they reflect genuine nonsense, not vocabulary drift). + */ + validateColumnTraits( + columns: WorkflowIrColumn[], + mode: "save" | "load" = "save", + ): TraitViolation[] { + const violations: TraitViolation[] = []; + + let intakeColumnCount = 0; + + for (const column of columns) { + const knownDefs: TraitDefinition[] = []; + + // Unknown trait ids: degradable. In save mode it's an error; in load mode + // it degrades to an advisory (the rule/vocabulary may have changed under + // a persisted definition). + for (const ct of column.traits) { + const def = this.traits.get(ct.trait); + if (!def) { + violations.push({ + code: "unknown-trait", + severity: mode === "load" ? "degraded" : "error", + columnId: column.id, + traitIds: [ct.trait], + message: `Column '${column.id}' references unknown trait '${ct.trait}'`, + }); + continue; + } + knownDefs.push(def); + } + + const flags = this.mergeFlags(knownDefs); + + // Capacity traits on this column (traits whose flags set countsTowardWip). + const capacityTraitIds = knownDefs + .filter((d) => d.flags.countsTowardWip) + .map((d) => d.id); + + if (flags.complete && flags.countsTowardWip) { + violations.push({ + code: "complete-with-wip", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "countsTowardWip"]), + message: `Column '${column.id}' is both a completion column and counts toward WIP — a terminal column cannot hold a capacity slot`, + }); + } + + if (capacityTraitIds.length > 1) { + violations.push({ + code: "two-capacity-traits", + severity: "error", + columnId: column.id, + traitIds: capacityTraitIds, + message: `Column '${column.id}' has more than one capacity (WIP) trait: ${capacityTraitIds.join(", ")}`, + }); + } + + if (flags.complete && flags.intake) { + violations.push({ + code: "complete-with-intake", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "intake"]), + message: `Column '${column.id}' is both a completion column and an intake column`, + }); + } + + if (flags.archived && flags.countsTowardWip) { + violations.push({ + code: "archived-with-wip", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["archived", "countsTowardWip"]), + message: `Column '${column.id}' is archived but counts toward WIP — archived cards must not hold capacity`, + }); + } + + if (flags.intake) intakeColumnCount += 1; + } + + if (intakeColumnCount > 1) { + violations.push({ + code: "multiple-intake-columns", + severity: "error", + columnId: null, + traitIds: [], + message: `Workflow has ${intakeColumnCount} intake columns; exactly one is allowed`, + }); + } + + return violations; + } + + private mergeFlags(defs: TraitDefinition[]): TraitFlags { + const merged: TraitFlags = {}; + for (const def of defs) { + for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) { + if (value) merged[key] = true; + } + } + return merged; + } + + private traitIdsWithFlags(defs: TraitDefinition[], flagKeys: (keyof TraitFlags)[]): string[] { + return defs + .filter((d) => flagKeys.some((k) => d.flags[k])) + .map((d) => d.id); + } +} + +// ── Module-level default registry ──────────────────────────────────────────── +// +// A single shared registry instance the built-ins register into and the engine +// wires hook impls into. Tests can construct fresh `new TraitRegistry()` +// instances for isolation. + +let defaultRegistry: TraitRegistry | undefined; + +export function getTraitRegistry(): TraitRegistry { + if (!defaultRegistry) defaultRegistry = new TraitRegistry(); + return defaultRegistry; +} + +/** Test-only: reset the shared registry (so built-in registration can be + * re-exercised in isolation). */ +export function __resetTraitRegistryForTests(): void { + defaultRegistry = undefined; +} + +// ── Convenience pass-throughs to the default registry ──────────────────────── + +export function getTrait(id: string): TraitDefinition | undefined { + return getTraitRegistry().getTrait(id); +} + +export function listTraits(): TraitDefinition[] { + return getTraitRegistry().listTraits(); +} + +export function resolveColumnFlags(column: WorkflowIrColumn): TraitFlags { + return getTraitRegistry().resolveColumnFlags(column); +} + +export function validateColumnTraits( + columns: WorkflowIrColumn[], + mode: "save" | "load" = "save", +): TraitViolation[] { + return getTraitRegistry().validateColumnTraits(columns, mode); +} + +/** + * Save-mode composition validation that THROWS (residual A). Runs the registry's + * `validateColumnTraits` in `save` mode and throws a {@link ColumnTraitValidationError} + * if any `error`-severity violations are present. `degraded` advisories are + * ignored (they never block a save). A no-op for `[]`/no-error columns. + */ +export function assertColumnTraitsValid(columns: WorkflowIrColumn[]): void { + const violations = getTraitRegistry() + .validateColumnTraits(columns, "save") + .filter((v) => v.severity === "error"); + if (violations.length > 0) throw new ColumnTraitValidationError(violations); +} + +export function registerTraitHookImpl( + traitId: string, + hookKind: TraitHookKind, + impl: TraitHookImpl, +): void { + getTraitRegistry().registerTraitHookImpl(traitId, hookKind, impl); +} + +/** Re-export for callers that only need the column-trait shape. */ +export type { WorkflowIrColumnTrait }; diff --git a/packages/core/src/trait-types.ts b/packages/core/src/trait-types.ts new file mode 100644 index 0000000000..e17b71b15c --- /dev/null +++ b/packages/core/src/trait-types.ts @@ -0,0 +1,125 @@ +/** + * Trait model (U2). A trait is declarative flags + optional config schema + + * optional executable lifecycle hook *descriptors*. Per KTD-2 there are two + * guard classes: + * - `guard` — sync, in-lock, fast/pure (DB reads only). BUILT-IN ONLY. + * - `gate` — async, pre-evaluated outside the lock; the plugin-facing + * surface. The verdict is recorded and re-checked cheaply in-lock. + * + * Hooks here are *descriptors* (what the trait declares it participates in); + * the executable implementations are registered separately by the engine via + * the core→engine DI seam (mirrors `setCreateFnAgent`). This keeps core + * engine-free: core never imports `@fusion/engine`. + */ + +/** The set of hook points a trait can declare (KTD-2). */ +export type TraitHookKind = "guard" | "gate" | "onEnter" | "onExit" | "releaseCondition"; + +/** All declarative trait flags. Derived from the Trait Vocabulary table. + * Every flag is optional; an absent flag means `false`. Flags compose by OR + * across a column's traits (see resolveColumnFlags). */ +export interface TraitFlags { + /** Cards in this column count against a WIP/capacity limit (substrate-enforced). */ + countsTowardWip?: boolean; + /** Terminal-success column; satisfies dependencies. RESTRICTED (built-in only). */ + complete?: boolean; + /** Globally archived; hidden from the board. RESTRICTED (built-in only). */ + archived?: boolean; + /** Hidden from the board lane (e.g. archived columns). */ + hiddenFromBoard?: boolean; + /** Leaving this column hard-cancels in-flight work (abort-on-exit). */ + abortOnExit?: boolean; + /** Cards cannot leave until explicit human approval. */ + humanReview?: boolean; + /** Where new cards land; exactly one per workflow (validated). */ + intake?: boolean; + /** Passive dwell column with a release condition. */ + hold?: boolean; + /** Participates in merge/PR orchestration (enqueues onto the merge queue). */ + mergeOrchestration?: boolean; + /** Entry to this column is blocked until the merge-class node completed. */ + mergeBlocker?: boolean; + /** Card progress/fields are reset on entry (reopen semantics). */ + resetOnEntry?: boolean; + /** Cumulative active-time accounting runs on enter/exit. */ + timing?: boolean; + /** Stall detection is evaluated by the sweep for cards dwelling here. */ + stallDetection?: boolean; + /** Emits notifications on enter/exit. */ + notify?: boolean; + /** A gate (advisory or blocking) is evaluated before entry. */ + gate?: boolean; +} + +/** The flag keys that are restricted to built-in traits (R22, KTD-7). A + * non-builtin (plugin) trait declaring any of these is rejected at + * registration. The sync `guard` hook descriptor is restricted separately. */ +export const RESTRICTED_TRAIT_FLAGS = ["complete", "archived"] as const; +export type RestrictedTraitFlag = (typeof RESTRICTED_TRAIT_FLAGS)[number]; + +/** A trait's hook descriptors — *what* the trait declares it participates in. + * `true` means "this trait has a hook of this kind"; the implementation is + * registered separately via the engine DI seam. */ +export interface TraitHookDescriptors { + /** Sync, in-lock guard. BUILT-IN ONLY (KTD-2/R22). */ + guard?: boolean; + /** Async, pre-evaluated gate. Plugin-facing surface. */ + gate?: boolean; + /** Post-commit, async, idempotent enter effect. */ + onEnter?: boolean; + /** Post-commit, async, idempotent exit effect. */ + onExit?: boolean; + /** Release-condition evaluation for hold columns (sweep-driven). */ + releaseCondition?: boolean; +} + +/** A declarative description of a trait's config schema. Lightweight by design + * (U2 ships the shapes; richer validation lands with each behavior unit). */ +export interface TraitConfigField { + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + /** For `enum` fields: the allowed values. */ + enumValues?: readonly string[]; + description?: string; +} + +export interface TraitConfigSchema { + fields: TraitConfigField[]; +} + +/** A trait definition: declarative flags + optional config schema + optional + * hook descriptors. Built-in traits set `builtin: true`. */ +export interface TraitDefinition { + id: string; + name: string; + description?: string; + flags: TraitFlags; + configSchema?: TraitConfigSchema; + hooks?: TraitHookDescriptors; + /** True for the 14 built-in traits; plugin/custom traits leave this falsy. + * Restricted capabilities (R22) are allowed only when `builtin` is true. */ + builtin?: boolean; +} + +// ── Hook implementation DI seam (core→engine) ─────────────────────────────── +// +// Implementations of trait hooks are NOT defined in core (core is engine-free). +// The engine registers them via `registerTraitHookImpl` the way it wires +// `setCreateFnAgent`. Core resolves an implementation through +// `getTraitHookImpl`; an unregistered hook resolves to a no-op (the registry's +// `resolveTraitHook` returns a no-op + an audit warning, see trait-registry). +// +// The impl signature is intentionally opaque here: core never invokes hooks +// directly (the store/sweep do, in engine-adjacent code), so core only needs to +// store/retrieve the registration. Using `unknown` keeps core free of engine +// types while remaining type-safe at the registration boundary. + +/** A registered hook implementation. Opaque to core; the engine supplies a + * concrete callable and casts at its own call sites. */ +export type TraitHookImpl = (...args: unknown[]) => unknown; + +/** Stable key for a (traitId, hookKind) implementation registration. */ +export function traitHookKey(traitId: string, hookKind: TraitHookKind): string { + return `${traitId}::${hookKind}`; +} diff --git a/packages/core/src/transition-pending.ts b/packages/core/src/transition-pending.ts new file mode 100644 index 0000000000..6f09f324ff --- /dev/null +++ b/packages/core/src/transition-pending.ts @@ -0,0 +1,115 @@ +/** + * Store-side read/write helpers for the crash-safe `tasks.transitionPending` + * marker (U3). + * + * These operate on a minimal db handle (anything exposing a `prepare` that + * returns a statement with `.get`/`.run`) so they can be unit-tested against a + * raw {@link import("./db.js").Database} without dragging in `store.ts`. U4 owns + * wiring these into `moveTaskInternal`'s transaction and the recovery sweep; + * this module is the clean seam they will call. + * + * The marker is written in the same transaction as the column change (KTD-2) and + * cleared once post-commit hooks complete. Recovery reads it back exclusively + * from SQLite (the authoritative store per ADR-0001). + */ + +import { + type TransitionPending, + deserializeTransitionPending, + serializeTransitionPending, +} from "./transition-types.js"; + +/** Minimal statement surface the helpers need (subset of node:sqlite's StatementSync). */ +interface MarkerStatement { + get(...params: unknown[]): unknown; + run(...params: unknown[]): unknown; +} + +/** Minimal db handle: just enough to prepare statements. Satisfied by `Database`. */ +export interface TransitionPendingDbHandle { + prepare(sql: string): MarkerStatement; +} + +/** + * Read the pending marker for a task. Returns `null` when the column is NULL, + * empty, or holds malformed JSON (a corrupt marker must never throw on a + * recovery path — it degrades to "no pending work" and the row is treated as + * settled). Returns `undefined` only when the task row does not exist. + */ +export function readTransitionPending( + db: TransitionPendingDbHandle, + taskId: string, +): TransitionPending | null | undefined { + const row = db + .prepare(`SELECT transitionPending FROM tasks WHERE id = ?`) + .get(taskId) as { transitionPending: string | null } | undefined; + if (row === undefined) return undefined; + if (row.transitionPending == null || row.transitionPending === "") return null; + return deserializeTransitionPending(row.transitionPending); +} + +/** + * Write (set or replace) the pending marker for a task. Intended to run inside + * the same transaction as the column change (U4). Stores the JSON-serialized + * marker into `tasks.transitionPending`. + */ +export function writeTransitionPending( + db: TransitionPendingDbHandle, + taskId: string, + pending: TransitionPending, +): void { + db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run( + serializeTransitionPending(pending), + taskId, + ); +} + +/** + * Clear the pending marker for a task (sets the column to NULL). Called once all + * post-commit hooks for the transition have completed. + */ +export function clearTransitionPending(db: TransitionPendingDbHandle, taskId: string): void { + db.prepare(`UPDATE tasks SET transitionPending = NULL WHERE id = ?`).run(taskId); +} + +/** Result of reconciling a marker's `hooksRemaining` against the known hook set. */ +export interface ReconcileHooksResult { + /** Hooks that survived: still registered/known and owed execution. */ + hooksRemaining: string[]; + /** + * Audit warnings for each dropped hook entry — e.g. a hook belonging to a + * now-uninstalled plugin. One human-readable message per dropped entry so the + * recovery sweep can emit a degraded-hook audit event and complete the marker + * rather than leaving the card stuck waiting for a missing handler. + */ + warnings: string[]; +} + +/** + * Reconcile a marker's `hooksRemaining` against the set of currently-known hook + * IDs. Entries no longer present (e.g. a plugin hook removed by uninstall) are + * dropped and surfaced as audit warnings. Pure — no DB access — so U4/U8 can + * call it in or out of a transaction. + * + * This covers the U3-level slice of the "missing-plugin-hook" scenario: the + * type/helper guarantees a dangling hook entry resolves to a dropped entry plus + * a warning, never an indefinitely-stuck marker. The actual recovery wiring is + * U4/U8. + */ +export function reconcileHooksRemaining( + hooksRemaining: readonly string[], + knownHookIds: ReadonlySet, +): ReconcileHooksResult { + const surviving: string[] = []; + const warnings: string[] = []; + for (const hookId of hooksRemaining) { + if (knownHookIds.has(hookId)) { + surviving.push(hookId); + } else { + warnings.push( + `Dropping unknown transition hook "${hookId}" from transitionPending marker (handler not registered; likely an uninstalled plugin)`, + ); + } + } + return { hooksRemaining: surviving, warnings }; +} diff --git a/packages/core/src/transition-types.ts b/packages/core/src/transition-types.ts new file mode 100644 index 0000000000..728445afee --- /dev/null +++ b/packages/core/src/transition-types.ts @@ -0,0 +1,189 @@ +/** + * Typed transition contract (U3). + * + * `moveTaskInternal` (the single transition authority, KTD-3/R13) stops throwing + * bare strings on a rejected move and instead returns a typed {@link TransitionResult}. + * The rejection shape is shared verbatim across surfaces — the dashboard drop + * handler, the CLI, the HTTP move endpoint, and the recovery sweep — so every + * caller speaks one rejection contract. Because the rejection crosses the HTTP + * API boundary, the type is intentionally a flat, JSON-safe object (no class + * instances, no functions, no `undefined`-only fields that would survive a JSON + * round-trip differently than declared) and ships with explicit + * (de)serialization helpers below. + * + * The {@link TransitionPending} marker is the crash-safe hook protocol (KTD-2/KTD-9): + * it is written in the same SQLite transaction as the column change and records + * which post-commit, idempotent enter/exit hooks still owe execution. A crash + * mid-transition leaves the marker behind; the recovery sweep re-reads it from + * SQLite (the authoritative store per ADR-0001 — `task.json` is a stale follower + * across a crash) and re-runs the remaining idempotent hooks. The marker, like + * the rejection, crosses no class boundary and round-trips cleanly through JSON. + */ + +/** + * Reason codes for a rejected transition. Stable string literals — they are + * persisted in audit and matched by surfaces to choose user-facing copy, so + * they must not change without a migration of the consumers. + */ +export type TransitionRejectionCode = + | "guard-rejected" + | "capacity-exhausted" + | "unknown-column" + | "workflow-mismatch" + | "merge-blocked"; + +/** The full, immutable set of rejection codes (handy for exhaustive validation). */ +export const TRANSITION_REJECTION_CODES: readonly TransitionRejectionCode[] = [ + "guard-rejected", + "capacity-exhausted", + "unknown-column", + "workflow-mismatch", + "merge-blocked", +] as const; + +/** + * A typed transition rejection. Flat and JSON-safe by construction. + * + * - `code` — machine-stable {@link TransitionRejectionCode}. + * - `messageKey` — i18n key the surface resolves to user-facing copy (never a + * pre-translated string; translation is the surface's job). + * - `retryable` — whether re-issuing the same move could succeed later (e.g. a + * capacity exhaustion frees up) versus a structural rejection that will not + * (e.g. unknown column). + * - `detail` — optional, non-localized diagnostic context for audit/logs only. + */ +export interface TransitionRejection { + code: TransitionRejectionCode; + messageKey: string; + retryable: boolean; + detail?: string; +} + +/** + * Result of an attempted transition. Discriminated on `ok` so callers branch + * exhaustively. The success arm carries the resolved destination column so the + * caller need not re-read it. + */ +export type TransitionResult = + | { ok: true; toColumn: string } + | { ok: false; rejection: TransitionRejection }; + +/** + * Crash-safe marker persisted alongside the column change. `hooksRemaining` + * holds the IDs of post-commit enter/exit hooks that have not yet completed; + * recovery re-runs exactly these (idempotently) and clears the marker when the + * list empties. `startedAt` is an epoch-millis timestamp used for stall/age + * diagnostics and ordering during recovery. + */ +export interface TransitionPending { + toColumn: string; + hooksRemaining: string[]; + startedAt: number; +} + +// --------------------------------------------------------------------------- +// Helper constructors +// --------------------------------------------------------------------------- + +/** + * Construct a {@link TransitionRejection}. `detail` is omitted from the object + * when not supplied so the serialized shape stays minimal and stable. + */ +export function makeTransitionRejection( + code: TransitionRejectionCode, + messageKey: string, + retryable: boolean, + detail?: string, +): TransitionRejection { + const rejection: TransitionRejection = { code, messageKey, retryable }; + if (detail !== undefined) { + rejection.detail = detail; + } + return rejection; +} + +/** Construct a successful {@link TransitionResult}. */ +export function transitionOk(toColumn: string): TransitionResult { + return { ok: true, toColumn }; +} + +/** Construct a rejected {@link TransitionResult} from a rejection. */ +export function transitionRejected(rejection: TransitionRejection): TransitionResult { + return { ok: false, rejection }; +} + +/** + * Construct a {@link TransitionPending} marker. `startedAt` defaults to now so + * the common call site (`moveTaskInternal` writing the marker in-txn) stays + * terse; callers reconstructing a marker from a stored value pass it explicitly. + * The `hooksRemaining` array is copied so the marker does not alias caller state. + */ +export function makeTransitionPending( + toColumn: string, + hooksRemaining: string[], + startedAt: number = Date.now(), +): TransitionPending { + return { toColumn, hooksRemaining: [...hooksRemaining], startedAt }; +} + +// --------------------------------------------------------------------------- +// (De)serialization — JSON-safe round-trip across the API boundary +// --------------------------------------------------------------------------- + +function isTransitionRejectionCode(value: unknown): value is TransitionRejectionCode { + return typeof value === "string" && (TRANSITION_REJECTION_CODES as readonly string[]).includes(value); +} + +/** Serialize a rejection to a JSON string for transport/persistence. */ +export function serializeTransitionRejection(rejection: TransitionRejection): string { + return JSON.stringify(rejection); +} + +/** + * Parse a rejection from a JSON string produced by + * {@link serializeTransitionRejection}. Returns `null` for malformed or + * structurally invalid input rather than throwing, so a corrupt audit payload + * can never crash a recovery path. + */ +export function deserializeTransitionRejection(json: string): TransitionRejection | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const obj = parsed as Record; + if (!isTransitionRejectionCode(obj.code)) return null; + if (typeof obj.messageKey !== "string") return null; + if (typeof obj.retryable !== "boolean") return null; + if (obj.detail !== undefined && typeof obj.detail !== "string") return null; + return makeTransitionRejection(obj.code, obj.messageKey, obj.retryable, obj.detail as string | undefined); +} + +/** Serialize a pending marker to a JSON string for the `tasks.transitionPending` column. */ +export function serializeTransitionPending(pending: TransitionPending): string { + return JSON.stringify(pending); +} + +/** + * Parse a pending marker from the JSON stored in `tasks.transitionPending`. + * Returns `null` for malformed/invalid input. Non-string entries in + * `hooksRemaining` are dropped defensively (a corrupt array element must not + * strand the card); the structural shape is otherwise required. + */ +export function deserializeTransitionPending(json: string): TransitionPending | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const obj = parsed as Record; + if (typeof obj.toColumn !== "string") return null; + if (!Array.isArray(obj.hooksRemaining)) return null; + if (typeof obj.startedAt !== "number" || !Number.isFinite(obj.startedAt)) return null; + const hooksRemaining = obj.hooksRemaining.filter((h): h is string => typeof h === "string"); + return { toColumn: obj.toColumn, hooksRemaining, startedAt: obj.startedAt }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 043f161575..a856b620fb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,15 +15,51 @@ export type { CapacityRiskSignal } from "./capacity.js"; export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; +/** + * The legacy default-workflow column set. Under + * `experimentalFeatures.workflowColumns` a task's valid columns are resolved + * from its workflow definition (the default workflow's column IDs are + * byte-identical to these — KTD-1). New flag-aware code should prefer the + * workflow-resolved path (`resolveAllowedColumns` / `workflowHasColumn` in + * `workflow-transitions.ts`) and trait-flag predicates over string equality; + * this enum remains the canonical id set for the built-in default workflow. + */ export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const; +/** + * The closed legacy column union — still the correct type for default-workflow + * column ids and the flag-OFF path. Movement entry points accept the wider + * {@link ColumnId}; flag-ON code validates ids against the task's resolved + * workflow at runtime. + */ export type Column = (typeof COLUMNS)[number]; +/** + * Column identifier accepted at task-movement entry points (KTD-1). + * Equals the legacy `Column` union for autocomplete purposes, but admits + * workflow-defined custom column ids; flag-ON paths validate the id against + * the task's resolved workflow at runtime, flag-OFF paths reject non-legacy + * ids exactly as before. + */ +export type ColumnId = Column | (string & {}); + export const DEFAULT_COLUMN: Column = "triage"; +/** + * Tests membership against the closed legacy column enum. Note: under the + * workflowColumns flag, column validity is workflow-scoped — flag-aware code + * should use `workflowHasColumn(ir, columnId)` (`workflow-transitions.ts`); + * this remains correct for the flag-OFF path and default-workflow ids. + */ export function isColumn(value: unknown): value is Column { return typeof value === "string" && (COLUMNS as readonly string[]).includes(value); } +/** + * @deprecated (workflowColumns, U12) Coerces an arbitrary value to a legacy + * column, DISCARDING workflow-defined custom column ids — lossy under the + * flag. Resolve and validate against the task's workflow instead. Retained + * for the legacy flag-OFF path while the flag exists. + */ export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUMN): Column { return isColumn(value) ? value : fallback; } @@ -645,6 +681,59 @@ export interface WorkflowStepResult { completedAt?: string; } +/** + * Lifecycle status of one persisted step instance (step-inversion U4, KTD-6). + * - `pending` — expanded but not yet started. + * - `in-progress` — actively executing inside its foreach sub-walk. + * - `awaiting-integration` — work complete on a parallel-mode branch, waiting + * for the ordered integration stage (KTD-11; unused at concurrency 1). + * - `completed` — terminal success (integrated in parallel mode). + * - `failed` — terminal failure. + */ +export type WorkflowRunStepInstanceStatus = + | "pending" + | "in-progress" + | "awaiting-integration" + | "completed" + | "failed"; + +/** + * Persisted run-state for one expanded step instance inside a foreach region + * (step-inversion U4, KTD-6). One row per `(taskId, runId, foreachNodeId, + * stepIndex)`; mirrors the `workflow_run_branches` posture. Resume reconstructs + * the instance set from `pinnedStepCount` + per-instance `currentNodeId` / + * `reworkCount`. `baselineSha` / `checkpointId` are the RETHINK reset anchors + * (previously in-memory, lost on restart). `branchName` / `integratedAt` and the + * `awaiting-integration` status serve parallel mode (KTD-11); null/unused at + * concurrency 1. This is the core row shape; the engine-side instance model is + * separate and engine-owned. + */ +export interface WorkflowRunStepInstance { + taskId: string; + runId: string; + /** Node id of the foreach region that expanded this instance. */ + foreachNodeId: string; + /** Zero-based index of the step this instance runs. */ + stepIndex: number; + /** Step count pinned at expansion; resume fails on mismatch with live steps[]. */ + pinnedStepCount: number; + /** Current sub-walk node id for the in-flight instance; null when not started. */ + currentNodeId?: string | null; + status: WorkflowRunStepInstanceStatus; + /** Git sha the RETHINK reset rewinds to; null when no baseline captured. */ + baselineSha?: string | null; + /** Session checkpoint to rewind to on RETHINK; null when none captured. */ + checkpointId?: string | null; + /** Number of rework cycles consumed against the rework budget. */ + reworkCount: number; + /** Per-instance branch name in worktree-isolation mode (KTD-11); null otherwise. */ + branchName?: string | null; + /** ISO-8601 timestamp the instance branch was integrated (KTD-11); null otherwise. */ + integratedAt?: string | null; + /** ISO-8601 timestamp of the last write to this row. */ + updatedAt: string; +} + /** A built-in workflow step template for one-click creation. */ export interface WorkflowStepTemplate { /** Unique template identifier (e.g., "documentation-review") */ @@ -1025,6 +1114,11 @@ export type StepStatus = "pending" | "in-progress" | "done" | "skipped"; export interface TaskStep { name: string; status: StepStatus; + /** Step-inversion (KTD-11): 0-indexed indices of steps this step depends on, + * parsed from the PROMPT.md `### Step N (depends: 1,2): Title` annotation + * (1-indexed step numbers in the doc → 0-indexed indices here). Absent for + * unannotated steps. */ + dependsOn?: number[]; } /** Correlation metadata linking a task mutation to the agent run that caused it. */ @@ -1773,7 +1867,9 @@ export interface Task { * tasks are hydrated from persistence. */ priority?: TaskPriority; - column: Column; + /** The task's current column id. Widened to {@link ColumnId} so workflow-defined + * custom columns are representable; flag-OFF paths only ever store legacy ids. */ + column: ColumnId; dependencies: string[]; /** User-requested hint for triage: prefer splitting into child tasks when appropriate. */ breakIntoSubtasks?: boolean; @@ -1782,6 +1878,14 @@ export interface Task { worktree?: string; steps: TaskStep[]; currentStep: number; + /** + * Workflow-defined custom task field values (KTD-13), keyed by field id. + * Persisted as the `tasks.customFields` JSON column. Treated as opaque by + * the core row⇄Task mapping and `updateTask`; the validation/write authority + * (type/enum/render checks against the workflow's field schema) lands in a + * later unit. Absent on legacy tasks. + */ + customFields?: Record; status?: string; /** ID of the in-progress task whose file scope overlaps with this task, * causing the scheduler to defer it. Set when the scheduler queues @@ -2170,7 +2274,9 @@ export interface TaskCreateInput { * Optional task importance level. Omitted values default to `normal`. */ priority?: TaskPriority; - column?: Column; + /** Initial column id. Widened to {@link ColumnId} (#1403) so a custom-column + * task can be replicated/created; flag-OFF creation only ever uses legacy ids. */ + column?: ColumnId; dependencies?: string[]; breakIntoSubtasks?: boolean; /** When true, this task is expected to complete without creating git commits. */ @@ -3950,6 +4056,15 @@ export const COLUMN_DESCRIPTIONS: Record = { archived: "Completed and archived", }; +/** + * @deprecated (workflowColumns, U12) The hardcoded legacy transition graph. + * Under `experimentalFeatures.workflowColumns`, transition validity is resolved + * from the task's workflow column graph (`resolveAllowedColumns` in + * `workflow-transitions.ts`) plus trait guards in `moveTaskInternal` — this + * constant is now only the flag-OFF authority and the parity oracle the default + * workflow is machine-checked against (transition-parity suite). Retained while + * the flag exists; do NOT remove until graduation + legacy-path deletion. + */ export const VALID_TRANSITIONS: Record = { // FN-4892: intake-side heuristics may cold-archive tasks before execution starts. triage: ["todo", "archived"], @@ -3983,6 +4098,8 @@ export interface ArchivedTaskEntry { dependencies: string[]; steps: TaskStep[]; currentStep: number; + /** Workflow-defined custom task field values (KTD-13) frozen at archive time. */ + customFields?: Record; size?: "S" | "M" | "L"; reviewLevel?: number; /** Execution mode for task implementation at time of archival. diff --git a/packages/core/src/workflow-capacity.ts b/packages/core/src/workflow-capacity.ts new file mode 100644 index 0000000000..e34ae46eab --- /dev/null +++ b/packages/core/src/workflow-capacity.ts @@ -0,0 +1,121 @@ +/** + * Workflow capacity resolution (U6, KTD-10, R9 capacity half). + * + * WIP/capacity limits are trait *configuration*; their *enforcement* is a + * substrate capability that runs INSIDE `moveTaskInternal`'s transaction and is + * NEVER bypassable (not a guard — runs regardless of bypassGuards/recoveryRehome + * /moveSource). This module is the pure resolution layer shared by both the + * in-txn check (`store.ts`) and the hold/release sweep (`@fusion/engine` + * `hold-release.ts`): given a workflow IR + a column id + settings it answers + * - does this column have a `wip` (capacity) trait? + * - what is its effective limit (read-through to `settings.maxConcurrent` for + * the default workflow's in-progress column so the legacy knob keeps working + * — U6 scheduler-integration half)? + * - does its config opt into counting mid-`transitionPending` cards? + * + * It performs NO DB access and NO counting — the caller owns the count (the + * store counts in-txn; the sweep counts from a listTasks snapshot). Keeping the + * resolution pure means the two enforcement points can never disagree on what a + * limit *is*, only on the live count, which is exactly the serialization the + * in-txn check arbitrates (two holds, one slot → one wins). + */ + +import type { Settings } from "./types.js"; +import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; +import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js"; +import { getTraitRegistry } from "./trait-registry.js"; + +/** The default-workflow column whose WIP limit read-through is + * `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */ +const DEFAULT_WIP_COLUMN_ID = "in-progress"; + +/** U6 (KTD-10): sentinel effective-workflow id for default-workflow + * (null-selection) tasks, so they all share one per-column capacity pool. It + * is not a real workflow row id (no `builtin:`/custom collision possible). */ +export const DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__"; + +/** Resolved capacity configuration for a single column. */ +export interface ColumnCapacity { + /** True when the column carries a capacity (`wip`/`countsTowardWip`) trait. */ + hasCapacity: boolean; + /** The effective max concurrent cards. `Infinity` means "no finite limit" + * (a capacity trait with no resolvable limit does not gate). */ + limit: number; + /** Whether mid-`transitionPending` cards (holding their destination slot from + * commit time) count toward the limit. Defaults true: a card that has + * committed its move into the column holds the slot even before its + * post-commit hooks finish (KTD-10). */ + countPending: boolean; +} + +const NO_CAPACITY: ColumnCapacity = { hasCapacity: false, limit: Infinity, countPending: true }; + +function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return undefined; + return v2.columns.find((c) => c.id === columnId); +} + +/** True when the IR's column set is exactly the default-workflow column ids. */ +function isDefaultWorkflowColumns(ir: WorkflowIr): boolean { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return false; + const ids = v2.columns.map((c) => c.id); + if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false; + const set = new Set(ids); + return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id)); +} + +/** + * Resolve the capacity configuration for `columnId` under `ir`. + * + * Limit resolution order: + * 1. An explicit numeric `limit` in the column's `wip` trait config wins. + * 2. Otherwise, for the DEFAULT workflow's `in-progress` column, read through + * to `settings.maxConcurrent` (default 2) so the legacy knob keeps working + * and flag-ON default-workflow scheduling matches flag-OFF (legacy parity). + * 3. Otherwise the column has a capacity trait but no resolvable finite limit + * → `Infinity` (does not gate; the trait is inert until configured). + */ +export function resolveColumnCapacity( + ir: WorkflowIr, + columnId: string, + settings?: Pick | undefined, +): ColumnCapacity { + const column = findColumn(ir, columnId); + if (!column) return NO_CAPACITY; + + const flags = getTraitRegistry().resolveColumnFlags(column); + if (!flags.countsTowardWip) return NO_CAPACITY; + + // The capacity trait config (the `wip` trait carries `limit` + `countPending`). + // Find the first trait config whose trait sets countsTowardWip. + let configLimit: number | undefined; + let countPending = true; + for (const ct of column.traits) { + const def = getTraitRegistry().getTrait(ct.trait); + if (!def?.flags.countsTowardWip) continue; + const cfg = ct.config ?? {}; + if (typeof cfg.limit === "number" && Number.isFinite(cfg.limit)) { + configLimit = cfg.limit; + } + if (typeof cfg.countPending === "boolean") { + countPending = cfg.countPending; + } + break; + } + + let limit: number; + if (configLimit !== undefined) { + limit = configLimit; + } else if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) { + // Read-through: legacy maxConcurrent maps onto the default workflow's + // in-progress WIP limit (U6 scheduler integration). + const maxConcurrent = settings?.maxConcurrent; + limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2; + } else { + limit = Infinity; + } + + return { hasCapacity: true, limit, countPending }; +} diff --git a/packages/core/src/workflow-columns-settings.ts b/packages/core/src/workflow-columns-settings.ts new file mode 100644 index 0000000000..e5a946546f --- /dev/null +++ b/packages/core/src/workflow-columns-settings.ts @@ -0,0 +1,18 @@ +import { isExperimentalFeatureEnabled } from "./experimental-features.js"; +import type { Settings } from "./types.js"; + +/** + * The `experimentalFeatures.workflowColumns` flag (KTD-8). OFF: the legacy + * enum/`VALID_TRANSITIONS` path runs untouched. ON: `moveTaskInternal` resolves + * each task's workflow column graph + trait guards. Default OFF until the + * transition-parity suite and field observations prove zero drift (U12). + * + * Mirrors `isSandboxExperimentalEnabled` / `isEvalsViewEnabled` — a thin, + * named accessor over the shared experimental-features map so the literal flag + * key lives in exactly one place. + */ +export function isWorkflowColumnsEnabled( + settings: Pick | undefined, +): boolean { + return isExperimentalFeatureEnabled(settings, "workflowColumns"); +} diff --git a/packages/core/src/workflow-definition-types.ts b/packages/core/src/workflow-definition-types.ts index 3383815a9d..60aee809e4 100644 --- a/packages/core/src/workflow-definition-types.ts +++ b/packages/core/src/workflow-definition-types.ts @@ -40,4 +40,22 @@ export interface WorkflowDefinitionUpdate { description?: string; ir?: WorkflowIr; layout?: Record; + /** + * U5 (R20): when an IR update removes a column that still holds cards, the + * update is blocked with a typed {@link import("./workflow-reconciliation.js").OccupiedColumnsError} + * unless `rehomeTo` is supplied — an explicit "save and re-home occupants to + * column X" target. The target must survive in the new IR. Only consulted when + * the `workflowColumns` flag is ON. + */ + rehomeTo?: string; + /** + * U11/KTD-13: when an IR update changes a custom field's type incompatibly for + * tasks that already hold a value under that field, the update is blocked with + * a typed {@link import("./workflow-reconciliation.js").IncompatibleFieldChangeError} + * unless `coerce` is supplied. `"drop"` discards the now-incompatible stored + * values; `"keep-orphaned"` retains them as orphans (rendered under the + * orphaned-fields disclosure). Removing a field outright always orphans (never + * blocks). Mirrors the `rehomeTo` conflict-resolution posture for columns. + */ + coerce?: "drop" | "keep-orphaned"; } diff --git a/packages/core/src/workflow-ir-resolver.ts b/packages/core/src/workflow-ir-resolver.ts new file mode 100644 index 0000000000..c2c703aa29 --- /dev/null +++ b/packages/core/src/workflow-ir-resolver.ts @@ -0,0 +1,79 @@ +/** + * Single source of truth for the workflow-IR resolution rule. + * + * The selection → builtin/custom → default-fallback rule was independently + * reimplemented in engine/hold-release.ts, engine/merge-trait.ts, + * engine/plugin-runner.ts (which bypassed the public API via getDatabase()), + * and dashboard/board-workflows.ts, with behavioral divergence already creeping + * in (GitHub #1402). This module consolidates the read-only resolution into one + * pair of helpers built on the *public* store surface so every call site shares + * one implementation. + * + * A missing/corrupt definition degrades to the built-in default workflow so + * resolution never throws. The store-private, txn-hot `resolveTaskWorkflowIrSync` + * stays separate by design. + */ + +import { getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; +import type { WorkflowIr } from "./workflow-ir-types.js"; + +/** Minimal store surface the resolver needs (public APIs only). */ +export interface WorkflowIrResolverStore { + getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined; + getWorkflowDefinition(id: string): Promise<{ ir: string | WorkflowIr } | undefined>; +} + +/** + * Resolve a workflow IR by its id (built-in or custom). + * + * @param irCache optional cache keyed by workflowId so each distinct workflow's + * IR (and its definition fetch) is resolved at most once per caller-scoped + * sweep. Hits short-circuit before any builtin/db lookup. + */ +export async function resolveWorkflowIrById( + store: Pick, + workflowId: string, + irCache?: Map, +): Promise { + const cached = irCache?.get(workflowId); + if (cached) return cached; + + if (isBuiltinWorkflowId(workflowId)) { + const builtin = getBuiltinWorkflow(workflowId); + const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + const resolved = typeof ir === "string" ? parseWorkflowIr(ir) : ir; + irCache?.set(workflowId, resolved); + return resolved; + } + + try { + const def = await store.getWorkflowDefinition(workflowId); + if (!def) return BUILTIN_CODING_WORKFLOW_IR; + const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir; + irCache?.set(workflowId, ir); + return ir; + } catch { + return BUILTIN_CODING_WORKFLOW_IR; + } +} + +/** + * Resolve a task's workflow IR via its selection. A null/absent selection or any + * lookup failure degrades to the built-in default workflow. + */ +export async function resolveWorkflowIrForTask( + store: WorkflowIrResolverStore, + taskId: string, + irCache?: Map, +): Promise { + let workflowId: string | undefined; + try { + workflowId = store.getTaskWorkflowSelection(taskId)?.workflowId; + } catch { + return BUILTIN_CODING_WORKFLOW_IR; + } + if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR; + return resolveWorkflowIrById(store, workflowId, irCache); +} diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index c5e2e537e2..b96e538fb5 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -1,8 +1,28 @@ -export type WorkflowIrNodeKind = "start" | "prompt" | "script" | "gate" | "end"; +/** Node kinds. v1 kinds (start/prompt/script/gate/end) plus the v2 additions: + * `hold` (passive dwell column states), `split`/`join` (parallel fan-out), and + * the step-inversion additions (FN step-inversion, KTD-3/4/12/15): + * `foreach` (runtime-expanding per-step template region), `step-review` + * (per-step review verdicts as outcome edges), `parse-steps` (graph-native + * step-list parsing), and `code` (sandboxed TypeScript). */ +export type WorkflowIrNodeKind = + | "start" + | "prompt" + | "script" + | "gate" + | "end" + | "hold" + | "split" + | "join" + | "foreach" + | "step-review" + | "parse-steps" + | "code"; export interface WorkflowIrNode { id: string; kind: WorkflowIrNodeKind; + /** v2: the column this node is placed in. Must reference a defined column id. */ + column?: string; config?: Record; } @@ -10,11 +30,122 @@ export interface WorkflowIrEdge { from: string; to: string; condition?: string; + /** Step-inversion (KTD-5): `rework` edges are the only legal cycles, scoped to + * one foreach template instance and bounded by the foreach `maxReworkCycles`. + * They are exempt from cycle/parallelism complaints. */ + kind?: "rework"; } -export interface WorkflowIr { +/** Step-inversion (KTD-3): config for a `foreach` node — a runtime-expanding + * template region instantiated once per planned step. + * Defaults: `mode` sequential; `isolation` shared for sequential / worktree for + * parallel; `concurrency` parallel-only. */ +export interface WorkflowForeachConfig { + source: "task-steps"; + maxReworkCycles?: number; + mode?: "sequential" | "parallel"; + concurrency?: number; + isolation?: "shared" | "worktree"; + template: { + nodes: WorkflowIrNode[]; + edges: WorkflowIrEdge[]; + }; +} + +/** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the + * existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */ +export interface WorkflowIrArtifact { + key: string; + title?: string; + producedBy?: "planning" | "manual"; + role?: "step-source" | "context"; +} + +/** Step-inversion (KTD-13): the supported custom-field value types. */ +export type WorkflowFieldType = + | "string" + | "text" + | "number" + | "boolean" + | "enum" + | "multi-enum" + | "date" + | "url"; + +/** A single enum/multi-enum option (KTD-13). */ +export interface WorkflowFieldOption { + value: string; + label: string; + color?: string; +} + +/** Rendering instructions for a custom field (KTD-14). */ +export interface WorkflowFieldRender { + placement?: "card" | "detail" | "detail-section"; + widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle"; + badge?: boolean; +} + +/** Step-inversion (KTD-13): a workflow-defined custom task field. */ +export interface WorkflowFieldDefinition { + id: string; + name: string; + type: WorkflowFieldType; + required?: boolean; + default?: unknown; + options?: WorkflowFieldOption[]; + render?: WorkflowFieldRender; +} + +/** A single trait configuration applied to a column. The `trait` is an opaque + * registry id (resolved by the trait registry shipped in U2); `config` carries + * trait-specific options validated by that trait's schema. */ +export interface WorkflowIrColumnTrait { + trait: string; + config?: Record; +} + +/** A workflow-defined board column. */ +export interface WorkflowIrColumn { + id: string; + name: string; + traits: WorkflowIrColumnTrait[]; +} + +/** Release conditions for a `hold` node (KTD-2, R3). */ +export type WorkflowHoldRelease = + | "manual" + | "timer" + | "capacity" + | "dependency" + | "external-event"; + +/** Join synchronization mode (KTD-11). `quorum` requires `quorum.n` completed branches. */ +export type WorkflowJoinMode = "all" | "any" | { quorum: number }; + +/** What happens to sibling branches when one branch fails before the join (KTD-11). */ +export type WorkflowJoinBranchFailure = "fail-fast" | "collect"; + +/** A v1 workflow IR graph. Frozen by FN-5769; retained for back-compat. */ +export interface WorkflowIrV1 { version: "v1"; name: string; nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[]; } + +/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. + * Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13) + * declarations — both additive; absent on legacy graphs. */ +export interface WorkflowIrV2 { + version: "v2"; + name: string; + columns: WorkflowIrColumn[]; + nodes: WorkflowIrNode[]; + edges: WorkflowIrEdge[]; + artifacts?: WorkflowIrArtifact[]; + fields?: WorkflowFieldDefinition[]; +} + +/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ +export type WorkflowIr = WorkflowIrV1 | WorkflowIrV2; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 1e86e1bd4d..6a3cc5eff8 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1,4 +1,16 @@ -import type { WorkflowIr } from "./workflow-ir-types.js"; +import type { + WorkflowIr, + WorkflowIrColumn, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrNodeKind, + WorkflowIrV1, + WorkflowIrV2, + WorkflowHoldRelease, + WorkflowForeachConfig, + WorkflowFieldDefinition, + WorkflowFieldType, +} from "./workflow-ir-types.js"; export class WorkflowIrError extends Error { constructor(message: string) { @@ -7,13 +19,809 @@ export class WorkflowIrError extends Error { } } +const HOLD_RELEASE_KINDS: ReadonlySet = new Set([ + "manual", + "timer", + "capacity", + "dependency", + "external-event", +]); + +/** Seam config values that may not appear inside a parallel branch (KTD-11): + * one worktree/session per task and exclusive merge are physical constraints. + * Step-inversion (KTD-4) extends this posture: `step-execute` seam prompt nodes + * may never appear in a split branch either. */ +const SEAM_FORBIDDEN_IN_BRANCH: ReadonlySet = new Set([ + "execute", + "merge", + "step-execute", +]); + +/** Step-inversion field-type whitelist (KTD-13). */ +const WORKFLOW_FIELD_TYPES: ReadonlySet = new Set([ + "string", + "text", + "number", + "boolean", + "enum", + "multi-enum", + "date", + "url", +]); + +const FIELD_RENDER_PLACEMENTS: ReadonlySet = new Set([ + "card", + "detail", + "detail-section", +]); + +const FIELD_RENDER_WIDGETS: ReadonlySet = new Set([ + "select", + "radio", + "chips", + "input", + "textarea", + "toggle", +]); + +/** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10, + * reject <1). */ +const MAX_REWORK_CYCLES_CAP = 10; + +/** Parallel concurrency bounds (KTD-3): range 1..8. */ +const MAX_FOREACH_CONCURRENCY = 8; + +/** The implicit step-source artifact allowed when no artifacts are declared. */ +const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md"; + +/** True when a prompt node carries the `step-execute` seam (KTD-2/KTD-4). */ +function isStepExecuteNode(node: WorkflowIrNode): boolean { + return node.kind === "prompt" && node.config?.seam === "step-execute"; +} + +/** Default-workflow column ids in legacy enum order (KTD-1). */ +export const DEFAULT_WORKFLOW_COLUMN_IDS = [ + "triage", + "todo", + "in-progress", + "in-review", + "done", + "archived", +] as const; + +/** Place a v1 node into a synthesized default-workflow column by its seam. */ +function defaultColumnForNode(node: WorkflowIrNode): string { + const seam = node.config?.seam; + if (seam === "execute") return "in-progress"; + if (seam === "review") return "in-review"; + if (seam === "merge") return "in-review"; + return "todo"; +} + +/** The synthesized default-workflow columns used when upgrading a v1 graph. The + * trait set here is intentionally minimal (placement only); the full default + * workflow with traits is BUILTIN_CODING_WORKFLOW_IR. */ +function synthesizeDefaultColumns(): WorkflowIrColumn[] { + return DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })); +} + +/** Upgrade a v1 graph to v2 by synthesizing default columns and placing nodes + * by their seam (execute→in-progress, review/merge→in-review, others→todo). */ +function upgradeV1ToV2(ir: WorkflowIrV1): WorkflowIrV2 { + return { + version: "v2", + name: ir.name, + columns: synthesizeDefaultColumns(), + nodes: ir.nodes.map((node) => + node.column ? node : { ...node, column: defaultColumnForNode(node) }, + ), + edges: ir.edges, + }; +} + +function buildOutgoing(edges: WorkflowIrEdge[]): Map { + const outgoing = new Map(); + for (const edge of edges) { + const list = outgoing.get(edge.from); + if (list) list.push(edge); + else outgoing.set(edge.from, [edge]); + } + return outgoing; +} + +function seamOf(node: WorkflowIrNode): string | undefined { + const seam = node.config?.seam; + return typeof seam === "string" ? seam : undefined; +} + +/** + * Validate `split`/`join` parallelism (KTD-11): + * - every split has a reachable matching join (recursively for nested splits); + * - execute/merge seam nodes inside a branch reject (seam-in-branch); + * - join `quorum(n)` with n exceeding the split's branch count rejects. + */ +function validateParallelism( + nodes: WorkflowIrNode[], + outgoing: Map, + nodesById: Map, +): void { + const splits = nodes.filter((n) => n.kind === "split"); + + for (const split of splits) { + const branchEdges = outgoing.get(split.id) ?? []; + if (branchEdges.length < 2) { + throw new WorkflowIrError(`split '${split.id}' must fan out into at least two branches`); + } + + // Walk each branch forward until the matching join is reached. Track join + // hit-counts and ensure every branch reaches the SAME join (nested splits + // resolve to their own join first, so balanced nesting still terminates). + const joinsReached = new Set(); + for (const edge of branchEdges) { + const join = walkBranchToJoin(edge.to, split.id, outgoing, nodesById); + if (!join) { + throw new WorkflowIrError(`split '${split.id}' has a branch with no reachable matching join`); + } + joinsReached.add(join); + } + if (joinsReached.size !== 1) { + throw new WorkflowIrError(`split '${split.id}' branches converge on more than one join`); + } + const joinId = [...joinsReached][0]; + const join = nodesById.get(joinId)!; + + const mode = join.config?.mode; + if (mode && typeof mode === "object" && "quorum" in mode) { + const n = (mode as { quorum: unknown }).quorum; + if (typeof n !== "number" || !Number.isInteger(n) || n < 1) { + throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`); + } + if (n > branchEdges.length) { + throw new WorkflowIrError( + `join '${join.id}' quorum(${n}) exceeds the split's ${branchEdges.length} branches`, + ); + } + } + } +} + +/** Walk a single branch from `startNodeId` until a `join` node is reached. + * Rejects execute/merge seam nodes encountered inside the branch. Handles one + * level of nesting by recursing through inner splits to their inner join. */ +function walkBranchToJoin( + startNodeId: string, + ownerSplitId: string, + outgoing: Map, + nodesById: Map, +): string | undefined { + const visited = new Set(); + let cursor: string | undefined = startNodeId; + while (cursor && !visited.has(cursor)) { + visited.add(cursor); + const node = nodesById.get(cursor); + if (!node) return undefined; + + if (node.kind === "join") return node.id; + + if (node.kind === "split") { + // Nested split: resolve to its inner join, then continue from there. + const inner = (outgoing.get(node.id) ?? []) + .map((e) => walkBranchToJoin(e.to, node.id, outgoing, nodesById)) + .find(Boolean); + if (!inner) return undefined; + cursor = innerJoinNext(inner, outgoing); + continue; + } + + const seam = seamOf(node); + if (seam && SEAM_FORBIDDEN_IN_BRANCH.has(seam)) { + throw new WorkflowIrError( + `seam '${seam}' node '${node.id}' is forbidden inside a parallel branch of split '${ownerSplitId}'`, + ); + } + + const next = (outgoing.get(cursor) ?? []).find((e) => e.condition !== "failure"); + cursor = next?.to; + } + return undefined; +} + +/** The node following a join along its (non-failure) outgoing edge. */ +function innerJoinNext(joinId: string, outgoing: Map): string | undefined { + return (outgoing.get(joinId) ?? []).find((e) => e.condition !== "failure")?.to; +} + +// --------------------------------------------------------------------------- +// Step-inversion validation (FN step-inversion, U1) +// --------------------------------------------------------------------------- + +/** True for a `rework`-kind edge (KTD-5). */ +function isReworkEdge(edge: WorkflowIrEdge): boolean { + return edge.kind === "rework"; +} + +/** Collect the set of node ids reachable from `start` following non-rework edges + * (rework edges are intra-template back-edges; the top-level reachability / + * dominance analysis ignores them). */ +function reachableFrom( + start: string, + outgoing: Map, +): Set { + const seen = new Set(); + const queue = [start]; + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + for (const edge of outgoing.get(id) ?? []) { + if (isReworkEdge(edge)) continue; + if (!seen.has(edge.to)) queue.push(edge.to); + } + } + return seen; +} + +/** + * Validate a foreach `template` subgraph recursively (KTD-3): + * - non-empty; + * - exactly one entry (no incoming template edges) and one exit (no outgoing); + * - NO nested foreach; + * - `step-execute` seam nodes are legal here but never inside a split branch + * (SEAM_FORBIDDEN_IN_BRANCH already enforces this via validateParallelism); + * - rework edges legal only when both endpoints are inside this template; + * - step-review verdict routing rules (KTD-4). + */ +function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set): void { + const cfg = node.config as Partial | undefined; + if (!cfg || cfg.source !== "task-steps") { + throw new WorkflowIrError( + `foreach node '${node.id}' must declare source 'task-steps'`, + ); + } + const template = cfg.template; + if ( + !template || + !Array.isArray(template.nodes) || + !Array.isArray(template.edges) + ) { + throw new WorkflowIrError( + `foreach node '${node.id}' must declare a template with nodes and edges arrays`, + ); + } + if (template.nodes.length === 0) { + throw new WorkflowIrError(`foreach node '${node.id}' template must be non-empty`); + } + + // mode / isolation / concurrency (KTD-3). + const mode = cfg.mode ?? "sequential"; + if (mode !== "sequential" && mode !== "parallel") { + throw new WorkflowIrError( + `foreach node '${node.id}' mode must be 'sequential' or 'parallel'`, + ); + } + const isolation = cfg.isolation ?? (mode === "parallel" ? "worktree" : "shared"); + if (isolation !== "shared" && isolation !== "worktree") { + throw new WorkflowIrError( + `foreach node '${node.id}' isolation must be 'shared' or 'worktree'`, + ); + } + if (mode === "parallel" && isolation === "shared") { + throw new WorkflowIrError( + `foreach node '${node.id}' cannot combine mode 'parallel' with isolation 'shared' (concurrent writes in one worktree are unguardable races)`, + ); + } + if (cfg.concurrency !== undefined) { + if (mode !== "parallel") { + throw new WorkflowIrError( + `foreach node '${node.id}' concurrency is only valid in 'parallel' mode`, + ); + } + const c = cfg.concurrency; + if (typeof c !== "number" || !Number.isInteger(c) || c < 1 || c > MAX_FOREACH_CONCURRENCY) { + throw new WorkflowIrError( + `foreach node '${node.id}' concurrency must be an integer in 1..${MAX_FOREACH_CONCURRENCY}`, + ); + } + } + if (cfg.maxReworkCycles !== undefined) { + const m = cfg.maxReworkCycles; + if (typeof m !== "number" || !Number.isInteger(m) || m < 1) { + throw new WorkflowIrError( + `foreach node '${node.id}' maxReworkCycles must be an integer >= 1`, + ); + } + // >10 is clamped at parse time (clampForeachConfig); validation only rejects <1. + } + + const templateNodes = template.nodes; + const templateIds = new Set(templateNodes.map((n) => n.id)); + if (templateIds.size !== templateNodes.length) { + throw new WorkflowIrError( + `foreach node '${node.id}' template has duplicate node ids`, + ); + } + + // No nested foreach. + for (const inner of templateNodes) { + if (inner.kind === "foreach") { + throw new WorkflowIrError( + `foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`, + ); + } + } + + // Edge endpoints must reference template nodes; rework edges must stay intra-template. + for (const edge of template.edges) { + const fromInside = templateIds.has(edge.from); + const toInside = templateIds.has(edge.to); + if (!fromInside || !toInside) { + if (isReworkEdge(edge)) { + throw new WorkflowIrError( + `rework edge '${edge.from}' -> '${edge.to}' in foreach '${node.id}' must have both endpoints inside the same template`, + ); + } + throw new WorkflowIrError( + `foreach node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`, + ); + } + } + + // Single entry / single exit (ignoring rework back-edges, which intentionally + // create incoming edges to earlier template nodes). + const incoming = new Map(); + const outgoingCount = new Map(); + for (const edge of template.edges) { + if (isReworkEdge(edge)) continue; + incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); + outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1); + } + const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0); + const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0); + if (entries.length !== 1) { + throw new WorkflowIrError( + `foreach node '${node.id}' template must have exactly one entry node (found ${entries.length})`, + ); + } + if (exits.length !== 1) { + throw new WorkflowIrError( + `foreach node '${node.id}' template must have exactly one exit node (found ${exits.length})`, + ); + } + + // Recurse: validate the template as its own region for parallelism + verdict + // routing. step-execute nodes legal here (they are not validated as forbidden + // at top level — that check lives in validateStepExecutePlacement). + const templateById = new Map(templateNodes.map((n) => [n.id, n])); + const templateOutgoing = buildOutgoing(template.edges); + validateParallelism(templateNodes, templateOutgoing, templateById); + validateStepReviewRouting(templateNodes, templateOutgoing, templateById, true); + + // Defensive: top-level node ids and template node ids should not collide + // (instance identity is `#:`, but a raw collision + // is still confusing). + for (const id of templateIds) { + if (topLevelNodeIds.has(id)) { + throw new WorkflowIrError( + `foreach node '${node.id}' template node id '${id}' collides with a top-level node id`, + ); + } + } +} + +/** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4): + * reject any at the top level. (Inside-split-branch rejection is handled by + * SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */ +function validateStepExecutePlacement(topLevelNodes: WorkflowIrNode[]): void { + for (const node of topLevelNodes) { + if (isStepExecuteNode(node)) { + throw new WorkflowIrError( + `step-execute seam node '${node.id}' is only legal inside a foreach template`, + ); + } + } +} + +/** + * step-review verdict routing (KTD-4). For each step-review node: + * - it must have outgoing edges covering `outcome:approve` and `outcome:revise`; + * - `outcome:rethink` optional (defaults to the revise target with reset semantics); + * - `outcome:unavailable` optional; + * - a step-review node inside a split branch is advisory-only: it must NOT carry + * rework or `outcome:approve` routing. + */ +function validateStepReviewRouting( + nodes: WorkflowIrNode[], + outgoing: Map, + nodesById: Map, + insideForeachTemplate: boolean, +): void { + // Determine which nodes sit inside a split branch (advisory-only zone). + const inBranch = nodesInSplitBranches(nodes, outgoing, nodesById); + + for (const node of nodes) { + if (node.kind !== "step-review") continue; + if (node.config?.type !== "plan" && node.config?.type !== "code") { + throw new WorkflowIrError( + `step-review node '${node.id}' must declare type 'plan' or 'code'`, + ); + } + if (node.config.model !== undefined && typeof node.config.model !== "string") { + throw new WorkflowIrError( + `step-review node '${node.id}' model must be a string when present`, + ); + } + + const edges = outgoing.get(node.id) ?? []; + const conditions = new Set(edges.map((e) => e.condition)); + const hasRework = edges.some(isReworkEdge); + + if (inBranch.has(node.id)) { + // Advisory-only inside a split branch: no rework, no approve routing. + if (hasRework) { + throw new WorkflowIrError( + `step-review node '${node.id}' inside a split branch is advisory-only and may not have rework edges`, + ); + } + if (conditions.has("outcome:approve")) { + throw new WorkflowIrError( + `step-review node '${node.id}' inside a split branch is advisory-only and may not carry outcome:approve routing`, + ); + } + continue; + } + + // Main-path step-review: must route approve and revise. + if (!conditions.has("outcome:approve")) { + throw new WorkflowIrError( + `step-review node '${node.id}' must route outcome:approve`, + ); + } + if (!conditions.has("outcome:revise")) { + throw new WorkflowIrError( + `step-review node '${node.id}' must route outcome:revise`, + ); + } + void insideForeachTemplate; + } +} + +/** Compute the set of node ids that lie strictly inside some split..join branch + * region. Walks each split's branches forward to the join. Lightweight; used + * for the step-review advisory-only rule. */ +function nodesInSplitBranches( + nodes: WorkflowIrNode[], + outgoing: Map, + nodesById: Map, +): Set { + const inBranch = new Set(); + const splits = nodes.filter((n) => n.kind === "split"); + for (const split of splits) { + for (const edge of outgoing.get(split.id) ?? []) { + let cursor: string | undefined = edge.to; + const visited = new Set(); + while (cursor && !visited.has(cursor)) { + const id: string = cursor; + visited.add(id); + const n = nodesById.get(id); + if (!n || n.kind === "join") break; + inBranch.add(id); + const next: WorkflowIrEdge | undefined = (outgoing.get(id) ?? []).find( + (e) => !isReworkEdge(e) && e.condition !== "failure", + ); + cursor = next?.to; + } + } + } + return inBranch; +} + +/** + * Cycle detection across the top-level graph that EXEMPTS rework edges (KTD-5). + * Any non-rework cycle is rejected; rework edges (intra-template back-edges) are + * skipped. Run over the top-level graph; template internals are validated + * separately. + */ +function validateNoIllegalCycles( + nodes: WorkflowIrNode[], + outgoing: Map, +): void { + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + for (const n of nodes) color.set(n.id, WHITE); + + const visit = (id: string): void => { + color.set(id, GRAY); + for (const edge of outgoing.get(id) ?? []) { + if (isReworkEdge(edge)) continue; + const c = color.get(edge.to); + if (c === GRAY) { + throw new WorkflowIrError( + `Workflow IR has an illegal cycle (edge '${edge.from}' -> '${edge.to}'); only rework edges may form cycles`, + ); + } + if (c === WHITE) visit(edge.to); + } + color.set(id, BLACK); + }; + + for (const n of nodes) { + if (color.get(n.id) === WHITE) visit(n.id); + } +} + +/** + * Dominance check (KTD-3): every `foreach(source:"task-steps")` must be dominated + * by a `parse-steps` node — a parse-steps node lies on EVERY path from start to + * the foreach. Implemented via the classic "removal disconnects start from + * target" definition, which is correct for DAGs: for each parse-steps node, + * check whether the foreach is still reachable from start with that node removed. + * The foreach is dominated iff some parse-steps node's removal disconnects it. + */ +function validateForeachDominance( + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[], + outgoing: Map, +): void { + const startNode = nodes.find((n) => n.kind === "start"); + if (!startNode) return; // parse-time guarantees exactly one start. + const foreaches = nodes.filter( + (n) => n.kind === "foreach" && (n.config as { source?: unknown } | undefined)?.source === "task-steps", + ); + if (foreaches.length === 0) return; + const parseStepsNodes = nodes.filter((n) => n.kind === "parse-steps"); + + for (const fe of foreaches) { + // Reachable from start at all? + if (!reachableFrom(startNode.id, outgoing).has(fe.id)) { + throw new WorkflowIrError( + `foreach node '${fe.id}' is not reachable from the start node`, + ); + } + const dominated = parseStepsNodes.some((ps) => { + if (ps.id === fe.id) return false; + // Build outgoing with ps removed (as both source and target). + const trimmed = buildOutgoing( + edges.filter((e) => e.from !== ps.id && e.to !== ps.id), + ); + return !reachableFrom(startNode.id, trimmed).has(fe.id); + }); + if (!dominated) { + throw new WorkflowIrError( + `foreach node '${fe.id}' (source:'task-steps') must be dominated by a parse-steps node on every path from start`, + ); + } + } +} + +/** Validate `parse-steps` node config (KTD-12). */ +function validateParseStepsNodes(ir: WorkflowIrV2): void { + const declaredArtifacts = new Set((ir.artifacts ?? []).map((a) => a.key)); + const hasDeclaredArtifacts = (ir.artifacts ?? []).length > 0; + + for (const node of ir.nodes) { + if (node.kind !== "parse-steps") continue; + const cfg = node.config as { artifact?: unknown; parser?: unknown } | undefined; + const artifact = cfg?.artifact; + const parser = cfg?.parser; + if (typeof parser !== "string" || parser.trim() === "") { + throw new WorkflowIrError( + `parse-steps node '${node.id}' must declare a non-empty parser`, + ); + } + if (typeof artifact !== "string" || artifact.trim() === "") { + throw new WorkflowIrError( + `parse-steps node '${node.id}' must declare a non-empty artifact`, + ); + } + if (hasDeclaredArtifacts) { + if (!declaredArtifacts.has(artifact)) { + throw new WorkflowIrError( + `parse-steps node '${node.id}' references undeclared artifact '${artifact}'`, + ); + } + } else if (artifact !== IMPLICIT_DEFAULT_ARTIFACT) { + throw new WorkflowIrError( + `parse-steps node '${node.id}' references artifact '${artifact}', but only '${IMPLICIT_DEFAULT_ARTIFACT}' is allowed when no artifacts are declared`, + ); + } + } +} + +/** Validate `code` node config (KTD-15). TS is NOT compiled in core (esbuild + * check is engine/editor side). */ +function validateCodeNodes(nodes: WorkflowIrNode[]): void { + const MAX_SOURCE = 65536; + for (const node of nodes) { + if (node.kind !== "code") continue; + const cfg = node.config as { source?: unknown; timeoutMs?: unknown } | undefined; + const source = cfg?.source; + if (typeof source !== "string" || source.length === 0) { + throw new WorkflowIrError(`code node '${node.id}' must declare a non-empty source`); + } + if (source.length > MAX_SOURCE) { + throw new WorkflowIrError( + `code node '${node.id}' source exceeds ${MAX_SOURCE} characters`, + ); + } + if (cfg?.timeoutMs !== undefined) { + const t = cfg.timeoutMs; + if (typeof t !== "number" || !Number.isInteger(t) || t < 1000 || t > 300000) { + throw new WorkflowIrError( + `code node '${node.id}' timeoutMs must be an integer in 1000..300000`, + ); + } + } + } +} + +/** Validate `fields` declarations (KTD-13). */ +function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { + if (fields === undefined) return; + if (!Array.isArray(fields)) { + throw new WorkflowIrError("Workflow IR fields must be an array"); + } + const seen = new Set(); + for (const field of fields) { + if (!field || typeof field.id !== "string" || field.id === "") { + throw new WorkflowIrError("Workflow field must have a non-empty id"); + } + if (seen.has(field.id)) { + throw new WorkflowIrError(`Workflow IR has duplicate field id '${field.id}'`); + } + seen.add(field.id); + if (typeof field.name !== "string" || field.name === "") { + throw new WorkflowIrError(`Workflow field '${field.id}' must have a non-empty name`); + } + if (!WORKFLOW_FIELD_TYPES.has(field.type)) { + throw new WorkflowIrError( + `Workflow field '${field.id}' has unknown type '${String(field.type)}'`, + ); + } + const isEnum = field.type === "enum" || field.type === "multi-enum"; + if (isEnum) { + if (!Array.isArray(field.options) || field.options.length === 0) { + throw new WorkflowIrError( + `Workflow field '${field.id}' of type '${field.type}' must declare non-empty options`, + ); + } + const optSeen = new Set(); + for (const opt of field.options) { + if (!opt || typeof opt.value !== "string" || opt.value === "") { + throw new WorkflowIrError( + `Workflow field '${field.id}' option must have a non-empty value`, + ); + } + if (typeof opt.label !== "string" || opt.label === "") { + throw new WorkflowIrError( + `Workflow field '${field.id}' option '${opt.value}' must have a non-empty label`, + ); + } + if (optSeen.has(opt.value)) { + throw new WorkflowIrError( + `Workflow field '${field.id}' has duplicate option value '${opt.value}'`, + ); + } + optSeen.add(opt.value); + } + } else if (field.options !== undefined) { + throw new WorkflowIrError( + `Workflow field '${field.id}' of type '${field.type}' must not declare options`, + ); + } + if (field.render !== undefined) { + const r = field.render; + if (r.placement !== undefined && !FIELD_RENDER_PLACEMENTS.has(r.placement)) { + throw new WorkflowIrError( + `Workflow field '${field.id}' render.placement '${String(r.placement)}' is not allowed`, + ); + } + if (r.widget !== undefined && !FIELD_RENDER_WIDGETS.has(r.widget)) { + throw new WorkflowIrError( + `Workflow field '${field.id}' render.widget '${String(r.widget)}' is not allowed`, + ); + } + } + } +} + +function validateColumns(ir: WorkflowIrV2): void { + if (!Array.isArray(ir.columns)) { + throw new WorkflowIrError("Workflow IR v2 columns must be an array"); + } + const seen = new Set(); + for (const column of ir.columns) { + if (!column || typeof column.id !== "string" || !column.id) { + throw new WorkflowIrError("Workflow IR column must have a non-empty id"); + } + if (seen.has(column.id)) { + throw new WorkflowIrError(`Workflow IR has duplicate column id '${column.id}'`); + } + seen.add(column.id); + if (!Array.isArray(column.traits)) { + throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`); + } + } +} + +function validateV2(ir: WorkflowIrV2): void { + validateColumns(ir); + + const columnIds = new Set(ir.columns.map((c) => c.id)); + const nodesById = new Map(ir.nodes.map((n) => [n.id, n])); + + for (const node of ir.nodes) { + if (node.column !== undefined && !columnIds.has(node.column)) { + throw new WorkflowIrError( + `Workflow node '${node.id}' references undefined column '${node.column}'`, + ); + } + if (node.kind === "hold") { + const release = node.config?.release; + if (!HOLD_RELEASE_KINDS.has(release as WorkflowHoldRelease)) { + throw new WorkflowIrError( + `hold node '${node.id}' has unknown release kind '${String(release)}'`, + ); + } + } + } + + const outgoing = buildOutgoing(ir.edges); + validateParallelism(ir.nodes, outgoing, nodesById); + + // Step-inversion (U1) — additive validation. Order matters: validate node + // configs first, then structural rules. + const topLevelIds = new Set(ir.nodes.map((n) => n.id)); + validateStepExecutePlacement(ir.nodes); + for (const node of ir.nodes) { + if (node.kind === "foreach") validateForeach(node, topLevelIds); + } + validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); + validateParseStepsNodes(ir); + validateCodeNodes(ir.nodes); + validateFields(ir.fields); + + // Rework edges are legal only intra-template; any rework edge at the top level + // is rejected (template rework edges are validated inside validateForeach and + // never appear in ir.edges). + for (const edge of ir.edges) { + if (isReworkEdge(edge)) { + throw new WorkflowIrError( + `rework edge '${edge.from}' -> '${edge.to}' is only legal inside a foreach template`, + ); + } + } + + validateNoIllegalCycles(ir.nodes, outgoing); + validateForeachDominance(ir.nodes, ir.edges, outgoing); +} + +/** Clamp foreach `maxReworkCycles` > cap down to the cap, in place, mirroring the + * maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */ +function clampForeachConfigs(ir: WorkflowIrV2): void { + for (const node of ir.nodes) { + if (node.kind !== "foreach") continue; + const cfg = node.config as Partial | undefined; + if ( + cfg && + typeof cfg.maxReworkCycles === "number" && + cfg.maxReworkCycles > MAX_REWORK_CYCLES_CAP + ) { + cfg.maxReworkCycles = MAX_REWORK_CYCLES_CAP; + } + } +} + export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr { const value: unknown = typeof input === "string" ? JSON.parse(input) : input; if (!value || typeof value !== "object") { throw new WorkflowIrError("Workflow IR must be an object"); } const ir = value as WorkflowIr; - if (ir.version !== "v1") throw new WorkflowIrError("Workflow IR version must be v1"); + if (ir.version !== "v1" && ir.version !== "v2") { + throw new WorkflowIrError("Workflow IR version must be v1 or v2"); + } if (!Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) { throw new WorkflowIrError("Workflow IR nodes/edges must be arrays"); } @@ -22,9 +830,79 @@ export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr { if (startCount !== 1 || endCount !== 1) { throw new WorkflowIrError("Workflow IR must contain exactly one start and one end node"); } + + if (ir.version === "v1") { + // Read-path upgrade: v1 graphs become v2 with synthesized default columns + // and seam-based node placement. v1 fixtures keep parsing (FN-5769 contract). + return upgradeV1ToV2(ir); + } + + clampForeachConfigs(ir); + validateV2(ir); return ir; } +/** v1 node kinds (FN-5769). A pure-v1 graph uses only these; the v2-only kinds + * (hold/split/join) force v2 persistence. */ +const V1_NODE_KINDS: ReadonlySet = new Set([ + "start", + "prompt", + "script", + "gate", + "end", +]); + +/** + * Rollback compat (FN issue #1405): if `ir` is a v2 graph that is byte-for-byte + * equivalent to an upgraded-v1 graph — only v1 node kinds, no hold/split/join, + * and exactly the synthesized default columns at their seam-derived placement — + * downgrade it back to the v1 shape so pre-v2 binaries (which hard-reject + * version !== 'v1') can still load the row. Returns the original `ir` unchanged + * when any v2-only feature is present (custom columns, non-default placement, + * v2-only node kinds), since those genuinely require v2. + */ +export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { + if (ir.version !== "v2") return ir; + + // Any v2-only node kind means the graph cannot be represented in v1. + for (const node of ir.nodes) { + if (!V1_NODE_KINDS.has(node.kind)) return ir; + } + + // Step-inversion declarations (artifacts/fields) are v2-only features. + if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) { + return ir; + } + + // Columns must be exactly the synthesized default set, same ids, same order, + // with the minimal (placement-only) empty trait set. Any custom column, rename, + // reorder, or applied trait forces v2. + if (ir.columns.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return ir; + for (let i = 0; i < ir.columns.length; i++) { + const col = ir.columns[i]; + const expectedId = DEFAULT_WORKFLOW_COLUMN_IDS[i]; + if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) { + return ir; + } + } + + // Every node must sit in its default seam-derived column. A node placed + // elsewhere is a v2 feature (custom placement) and must stay v2. + for (const node of ir.nodes) { + if (node.column !== defaultColumnForNode(node)) return ir; + } + + // Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the + // result round-trips through a pre-v2 binary. (Re-reading it on a v2 binary + // re-upgrades it to the identical v2 graph via upgradeV1ToV2.) + return { + version: "v1", + name: ir.name, + nodes: ir.nodes.map(({ column: _column, ...rest }) => rest), + edges: ir.edges, + }; +} + export function serializeWorkflowIr(ir: WorkflowIr): string { return JSON.stringify(ir, null, 2); } diff --git a/packages/core/src/workflow-parity.ts b/packages/core/src/workflow-parity.ts index 56bc4a92f7..28e1b89379 100644 --- a/packages/core/src/workflow-parity.ts +++ b/packages/core/src/workflow-parity.ts @@ -1,4 +1,7 @@ -import type { RunAuditEvent } from "./types.js"; +import type { Column, RunAuditEvent } from "./types.js"; +import { VALID_TRANSITIONS } from "./types.js"; +import type { WorkflowIr } from "./workflow-ir-types.js"; +import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; export const WORKFLOW_PARITY_OBSERVED_MUTATION = "workflow:parity-observed" as const; export const WORKFLOW_PARITY_DRIFT_MUTATION = "workflow:parity-drift" as const; @@ -315,3 +318,168 @@ export function buildWorkflowObservation(parts: WorkflowObservationParts): Workf invariants: { ...DEFAULT_WORKFLOW_INVARIANTS, ...parts.invariants }, }; } + +// ── Transition parity (U12) ────────────────────────────────────────────────── +// +// The transition-parity suite (U4) proves, as a unit test, that the default +// workflow's resolved column adjacency equals the legacy VALID_TRANSITIONS +// graph. U12 surfaces the SAME comparison as a runtime check so the graduation +// gate can re-evaluate it against whatever IR is actually resolved for the +// default workflow in the field (not just the static fixture), catching a +// deliberately or accidentally drifted default-workflow adjacency. + +/** One adjacency disagreement between the legacy graph and the resolved IR. */ +export interface TransitionParityDiff { + /** The `from` column whose allowed-set diverged. */ + from: string; + /** Allowed targets per the legacy VALID_TRANSITIONS graph. */ + legacyAllowed: string[]; + /** Allowed targets per the resolved workflow IR column graph. */ + resolvedAllowed: string[]; +} + +export interface TransitionParityReport { + /** True when every legacy column's allowed-set matches the resolved IR's. */ + agree: boolean; + /** Per-column adjacency disagreements (empty when `agree`). */ + diffs: TransitionParityDiff[]; +} + +const LEGACY_COLUMNS = Object.keys(VALID_TRANSITIONS) as Column[]; + +function sortedUnique(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +/** + * Compare the default-workflow IR's resolved column adjacency against the legacy + * VALID_TRANSITIONS graph (R12 transition parity, machine-checked). For every + * legacy column, the resolved allowed-set must equal the legacy allowed-set + * exactly (allowed AND rejected). The IR must also recognize every legacy + * column. Any divergence is a graduation blocker. + */ +export function checkTransitionParity(ir: WorkflowIr): TransitionParityReport { + const diffs: TransitionParityDiff[] = []; + for (const from of LEGACY_COLUMNS) { + const legacyAllowed = sortedUnique(VALID_TRANSITIONS[from]); + // A column the resolved IR doesn't even define diverges by construction. + const resolvedAllowed = workflowHasColumn(ir, from) + ? sortedUnique(resolveAllowedColumns(ir, from)) + : []; + const equal = + legacyAllowed.length === resolvedAllowed.length && + legacyAllowed.every((value, index) => value === resolvedAllowed[index]); + if (!equal) diffs.push({ from, legacyAllowed, resolvedAllowed }); + } + return { agree: diffs.length === 0, diffs }; +} + +// ── Dual-accept disagreement counter (U12) ─────────────────────────────────── +// +// U6 logs `merge:dependency-parity-diff` audits whenever the explicit handoff +// marker and the complete-flag column disagree during the FN-5719 dual-accept +// window. The window CLOSES at graduation, so any disagreement above zero over +// the observation period blocks the flip. This surfaces the count (and the +// lease-parity counterpart) from the audit trail as a graduation signal. + +export const DUAL_ACCEPT_PARITY_MUTATIONS = [ + "merge:dependency-parity-diff", + "merge:lease-parity-diff", +] as const; + +const DUAL_ACCEPT_PARITY_MUTATION_SET = new Set(DUAL_ACCEPT_PARITY_MUTATIONS); + +export interface DualAcceptDisagreementReport { + /** Total dual-accept disagreement audit events in scope. */ + total: number; + /** Count per mutation type (dependency vs lease parity diff). */ + byMutationType: Record; +} + +/** + * Count the dual-accept marker/column disagreement audits (U6) in scope. Pure + * over the supplied events so the store can feed it whatever audit window the + * graduation report observes. + */ +export function countDualAcceptDisagreements( + events: readonly RunAuditEvent[], +): DualAcceptDisagreementReport { + const byMutationType: Record = {}; + let total = 0; + for (const event of events) { + const type = String(event.mutationType); + if (event.domain !== "database" || !DUAL_ACCEPT_PARITY_MUTATION_SET.has(type)) continue; + byMutationType[type] = (byMutationType[type] ?? 0) + 1; + total += 1; + } + return { total, byMutationType }; +} + +// ── Graduation report (U12) ────────────────────────────────────────────────── +// +// The flag default-flip criteria, aggregated into one report (KTD-8). The flip +// is a FIELD decision — this report is the GATE, not the trigger. `ready` is +// true only when ALL of: +// - the five-invariant dual-observe parity shows zero drift (drift === 0) over +// a non-empty observation window; +// - the default workflow's transition parity holds (no adjacency drift); +// - zero dual-accept marker/column disagreements over the window. + +export interface WorkflowColumnsGraduationReport { + /** Five-invariant dual-observe parity (from the audit trail). */ + parity: WorkflowParitySummary; + /** Default-workflow transition-graph parity vs VALID_TRANSITIONS. */ + transitionParity: TransitionParityReport; + /** Dual-accept marker/column disagreement count (U6). */ + dualAccept: DualAcceptDisagreementReport; + /** True only when every gate passes — the flag is eligible to default on. */ + ready: boolean; + /** Human-readable blockers when not ready (empty when ready). */ + blockers: string[]; +} + +export interface GraduationReportInputs { + /** Dual-observe parity summary (e.g. `store.getWorkflowParitySummary()`). */ + parity: WorkflowParitySummary; + /** The resolved default-workflow IR to transition-parity-check. */ + defaultWorkflowIr: WorkflowIr; + /** Audit events in the observation window for dual-accept counting. */ + dualAcceptEvents: readonly RunAuditEvent[]; +} + +/** + * Aggregate the flag default-flip criteria into a single graduation report + * (U12, absorbing plan 002's M-D). Pure: the caller assembles the inputs from + * the store's audit trail and resolved default workflow, and decides whether to + * flip the flag — this function only computes the gate. + */ +export function computeWorkflowColumnsGraduationReport( + inputs: GraduationReportInputs, +): WorkflowColumnsGraduationReport { + const { parity, defaultWorkflowIr, dualAcceptEvents } = inputs; + const transitionParity = checkTransitionParity(defaultWorkflowIr); + const dualAccept = countDualAcceptDisagreements(dualAcceptEvents); + + const blockers: string[] = []; + if (parity.observed === 0) { + blockers.push("no parity observations recorded yet (observation window empty)"); + } + if (parity.drift > 0) { + blockers.push(`five-invariant parity drift observed (${parity.drift} drift events)`); + } + if (!transitionParity.agree) { + const cols = transitionParity.diffs.map((d) => d.from).join(", "); + blockers.push(`default-workflow transition parity drifted (columns: ${cols})`); + } + if (dualAccept.total > 0) { + blockers.push(`dual-accept marker/column disagreements above zero (${dualAccept.total})`); + } + + return { + parity, + transitionParity, + dualAccept, + ready: blockers.length === 0, + blockers, + }; +} diff --git a/packages/core/src/workflow-reconciliation.ts b/packages/core/src/workflow-reconciliation.ts new file mode 100644 index 0000000000..e417c79a9e --- /dev/null +++ b/packages/core/src/workflow-reconciliation.ts @@ -0,0 +1,325 @@ +/** + * Workflow lifecycle reconciliation (U5, R15/R20). + * + * Defines the policy for every case where a card's column could stop existing + * under it: + * + * (a) workflow SWITCH — the task's selection changes. If the new workflow + * defines a column with the task's current column id, position is + * preserved; otherwise the card re-homes to the new workflow's entry + * (intake-flagged, falling back to the first) column. In-flight processing + * is aborted first via an injected abort callback (engine wires the real + * abort; core ships a safe no-op default + audit entry so core stays + * engine-free). + * + * (b) workflow EDIT removing an occupied column — the update path blocks with + * a typed {@link OccupiedColumnsError} listing per-column occupant counts. + * An explicit `rehomeTo` option allows the save plus re-home of every + * occupant (one audit event per card). + * + * (c) workflow DELETE with occupants — built-ins stay blocked; custom + * workflows re-home occupants to the DEFAULT workflow's entry column, + * clear their selection rows, and preserve task fields (preserveProgress + * semantics), one audit event per card. + * + * Re-homing moves go through `moveTask` with `moveSource: "engine"` + + * `bypassGuards` (a recovery-class move, KTD-9) — never a raw column write — so + * capacity (KTD-10) and the single transition authority (KTD-3) are honored. + * + * This module is pure policy + a DI seam. The store (and dashboard routes via + * the store) own the actual DB reads/writes and the `moveTask` call; this module + * supplies the column-resolution rules and the abort indirection so the policy + * is independently testable and reused identically across switch/edit/delete. + */ + +import type { + WorkflowIr, + WorkflowIrV2, + WorkflowIrColumn, + WorkflowFieldDefinition, +} from "./workflow-ir-types.js"; +import { resolveColumnFlags } from "./trait-registry.js"; +import { workflowHasColumn } from "./workflow-transitions.js"; + +// ── Entry-column resolution ────────────────────────────────────────────────── + +/** The v2 columns of an IR, or `[]` when (defensively) absent. */ +function columnsOf(ir: WorkflowIr): WorkflowIrColumn[] { + const v2 = ir as WorkflowIrV2; + return Array.isArray(v2.columns) ? v2.columns : []; +} + +/** + * The entry column id for a workflow: the intake-flagged column (resolved via + * the trait registry's effective-flag merge), falling back to the FIRST + * declared column. Returns `undefined` only when the workflow declares no + * columns at all (should never happen post-parse) — callers treat that as a + * non-reconcilable workflow and leave the card where it is. + */ +export function resolveEntryColumnId(ir: WorkflowIr): string | undefined { + const columns = columnsOf(ir); + if (columns.length === 0) return undefined; + for (const column of columns) { + if (resolveColumnFlags(column).intake) return column.id; + } + return columns[0].id; +} + +// ── (a) Workflow switch ────────────────────────────────────────────────────── + +/** The outcome of resolving where a card lands when its workflow switches. */ +export interface SwitchReconciliation { + /** The column the card should occupy under the new workflow. */ + targetColumn: string; + /** True when the card's current column id exists in the new workflow and was + * therefore preserved; false when it was re-homed to the entry column. */ + preserved: boolean; + /** The entry column the card would re-home to (always resolved, for audit). */ + entryColumn: string | undefined; +} + +/** + * Resolve where a card currently in `currentColumn` lands under `newWorkflowIr`. + * Same-id columns preserve position; otherwise the card re-homes to the new + * workflow's entry column. Pure — the caller performs the abort + move. + */ +export function resolveSwitchReconciliation( + newWorkflowIr: WorkflowIr, + currentColumn: string, +): SwitchReconciliation { + const entryColumn = resolveEntryColumnId(newWorkflowIr); + if (workflowHasColumn(newWorkflowIr, currentColumn)) { + return { targetColumn: currentColumn, preserved: true, entryColumn }; + } + // No same-id column: re-home to the entry column. When the new workflow + // declares no columns at all (entryColumn undefined), leave the card where it + // is rather than strand it in nowhere. + return { + targetColumn: entryColumn ?? currentColumn, + preserved: false, + entryColumn, + }; +} + +// ── (b) Workflow edit removing an occupied column ──────────────────────────── + +/** Per-column occupant count for a blocked edit/delete. */ +export interface ColumnOccupancy { + columnId: string; + count: number; +} + +/** + * Thrown by the store's update path (and surfaced as a structured 409 by the + * dashboard) when a workflow edit would remove one or more columns that still + * hold cards, and no `rehomeTo` was supplied. Carries the per-column occupant + * counts so the surface can prompt for a re-home target. + */ +export class OccupiedColumnsError extends Error { + readonly workflowId: string; + readonly occupancies: ColumnOccupancy[]; + constructor(workflowId: string, occupancies: ColumnOccupancy[]) { + const summary = occupancies + .map((o) => `${o.columnId} (${o.count})`) + .join(", "); + super( + `Workflow '${workflowId}' edit removes occupied column(s): ${summary}. ` + + `Re-home the occupants (rehomeTo) or move them out first.`, + ); + this.name = "OccupiedColumnsError"; + this.workflowId = workflowId; + this.occupancies = occupancies; + } +} + +/** + * Compute which currently-occupied columns would be removed by replacing the + * existing IR with `nextIr`. `occupantsByColumn` maps a column id to the number + * of cards currently in it (under this workflow). Returns one entry per removed + * column that still has occupants, in the existing IR's column order. + */ +export function computeRemovedOccupiedColumns( + existingIr: WorkflowIr, + nextIr: WorkflowIr, + occupantsByColumn: Map, +): ColumnOccupancy[] { + const nextIds = new Set(columnsOf(nextIr).map((c) => c.id)); + const removed: ColumnOccupancy[] = []; + for (const column of columnsOf(existingIr)) { + if (nextIds.has(column.id)) continue; + const count = occupantsByColumn.get(column.id) ?? 0; + if (count > 0) removed.push({ columnId: column.id, count }); + } + return removed; +} + +/** + * Thrown when a supplied `rehomeTo` names a column that does not exist in the + * post-edit workflow. Distinct from {@link OccupiedColumnsError} (which signals + * a conflict needing a re-home target) — this is a bad-request input error and + * the dashboard maps it to a 400, not a 409. + */ +export class InvalidRehomeTargetError extends Error { + readonly workflowId: string; + readonly rehomeTo: string; + constructor(workflowId: string, rehomeTo: string) { + super( + `Workflow '${workflowId}' has no column '${rehomeTo}' to re-home occupants into.`, + ); + this.name = "InvalidRehomeTargetError"; + this.workflowId = workflowId; + this.rehomeTo = rehomeTo; + } +} + +/** + * Validate that `rehomeTo` (when supplied for an edit that removes occupied + * columns) names a column that survives in `nextIr`. Throws when it does not, so + * occupants are never re-homed into a column that won't exist either. + */ +export function assertRehomeTargetValid(nextIr: WorkflowIr, rehomeTo: string): void { + if (!workflowHasColumn(nextIr, rehomeTo)) { + throw new InvalidRehomeTargetError( + (nextIr as WorkflowIrV2).name ?? "(unknown)", + rehomeTo, + ); + } +} + +// ── Custom-field schema-evolution reconciliation (U11/KTD-13) ──────────────── + +/** A field whose type changed incompatibly while tasks hold values under it. */ +export interface IncompatibleFieldChange { + fieldId: string; + fromType: string; + toType: string; + /** Number of tasks (under this workflow) currently holding a value for it. */ + occupantCount: number; +} + +/** + * Thrown by the workflow update path when an IR edit changes one or more custom + * fields' types incompatibly for tasks that already hold a value, and no + * `coerce` option was supplied. Mirrors {@link OccupiedColumnsError}: a typed, + * conflict-signaling error the surface maps to a 409 prompting for a coercion + * choice (`drop` | `keep-orphaned`). + */ +export class IncompatibleFieldChangeError extends Error { + readonly workflowId: string; + readonly changes: IncompatibleFieldChange[]; + constructor(workflowId: string, changes: IncompatibleFieldChange[]) { + const summary = changes + .map((c) => `${c.fieldId} (${c.fromType}→${c.toType}, ${c.occupantCount})`) + .join(", "); + super( + `Workflow '${workflowId}' edit changes field type(s) incompatibly: ${summary}. ` + + `Supply coerce ("drop" | "keep-orphaned") to proceed.`, + ); + this.name = "IncompatibleFieldChangeError"; + this.workflowId = workflowId; + this.changes = changes; + } +} + +/** The v2 fields of an IR, or `[]` when absent (v1 or undeclared). */ +function fieldsOf(ir: WorkflowIr): WorkflowFieldDefinition[] { + const v2 = ir as WorkflowIrV2; + return Array.isArray(v2.fields) ? v2.fields : []; +} + +/** Enum-kind sibling check (enum / multi-enum). */ +function sameEnumKind(a: string, b: string): boolean { + const enumKind = (t: string) => t === "enum" || t === "multi-enum"; + return enumKind(a) && enumKind(b); +} + +/** + * Compute which custom fields change type INCOMPATIBLY between `existingIr` and + * `nextIr` AND still have occupant tasks holding a value. A type is compatible + * with itself; enum↔multi-enum is treated as compatible-shape (values are + * re-validated against the new options at reconcile time — a value dropped by + * the new options orphans individually, not via a hard block). A field removed + * outright is NOT a conflict (removal always orphans, never blocks). Returns one + * entry per blocking change in the existing IR's field order. + * + * `occupantsByField` maps a field id to the count of tasks (under this workflow) + * currently holding a value for it. + */ +export function computeIncompatibleFieldChanges( + existingIr: WorkflowIr, + nextIr: WorkflowIr, + occupantsByField: Map, +): IncompatibleFieldChange[] { + const nextById = new Map(fieldsOf(nextIr).map((f) => [f.id, f])); + const changes: IncompatibleFieldChange[] = []; + for (const oldField of fieldsOf(existingIr)) { + const next = nextById.get(oldField.id); + if (!next) continue; // removed → orphan, not a block + if (next.type === oldField.type) continue; // identical type → fine + if (sameEnumKind(oldField.type, next.type)) continue; // enum↔multi-enum → soft + const occupantCount = occupantsByField.get(oldField.id) ?? 0; + if (occupantCount > 0) { + changes.push({ + fieldId: oldField.id, + fromType: oldField.type, + toType: next.type, + occupantCount, + }); + } + } + return changes; +} + +// ── Abort-on-switch DI seam (core stays engine-free) ───────────────────────── +// +// A workflow switch must abort the card's in-flight processing BEFORE the move +// (mirroring abort-on-exit, KTD-9). Aborting touches engine machinery (sessions +// / leases), which core cannot import. The engine wires its abort in via +// `setReconciliationAbort` (mirrors `setCreateFnAgent`); when unset (isolated +// core tests, or engine not loaded) the default is a safe no-op that records an +// audit breadcrumb so the bypass is visible — degraded, not crashed. + +/** What the store passes to the abort callback so the engine can locate the + * session/lease to abort and the store can record audit. */ +export interface ReconciliationAbortContext { + taskId: string; + fromColumn: string; + reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome"; +} + +/** The injected abort implementation. Returns nothing; failures must not throw + * (a failed abort degrades to an audit entry — it never strands the card). */ +export type ReconciliationAbort = (ctx: ReconciliationAbortContext) => void | Promise; + +let reconciliationAbort: ReconciliationAbort | undefined; + +/** + * Wire the engine's abort implementation into core. Called by `@fusion/engine` + * at module load; tests may register a stub (or leave it unset for the no-op). + * Passing `undefined` restores the default no-op. + */ +export function setReconciliationAbort(fn: ReconciliationAbort | undefined): void { + reconciliationAbort = fn; +} + +/** + * Run the wired abort, or the safe default no-op when none is registered. Always + * resolves (swallows abort errors) so reconciliation never wedges on a failing + * abort. Returns `true` when a real abort ran, `false` for the default no-op — + * the store records the appropriate audit either way. + */ +export async function runReconciliationAbort(ctx: ReconciliationAbortContext): Promise { + if (!reconciliationAbort) return false; + try { + await reconciliationAbort(ctx); + } catch { + // A failed abort must not strand the card — the caller still re-homes it, + // and records a degraded-abort audit. Swallow here. + } + return true; +} + +/** Test-only: reset the wired abort to the default no-op. */ +export function __resetReconciliationAbortForTests(): void { + reconciliationAbort = undefined; +} diff --git a/packages/core/src/workflow-transitions.ts b/packages/core/src/workflow-transitions.ts new file mode 100644 index 0000000000..ed81c1243d --- /dev/null +++ b/packages/core/src/workflow-transitions.ts @@ -0,0 +1,108 @@ +/** + * Workflow-resolved transition adjacency (U4, R4/R9/R13). + * + * `moveTaskInternal` (flag ON) and `board.ts` both derive "which columns can a + * card move to from here" from the SAME helper so the two surfaces never + * diverge — `resolveAllowedColumns(ir, fromColumn)`. + * + * ── Why an explicit adjacency, not pure graph-derivation ────────────────────── + * + * The plan asks: derive allowed column adjacency from node placement + edges, + * and for the DEFAULT workflow it MUST reproduce `VALID_TRANSITIONS` exactly. + * Pure graph-edge derivation CANNOT reproduce it: `VALID_TRANSITIONS` encodes + * backward/reopen edges (in-review → todo, done → todo, archived → done, …) and + * cross edges (in-progress → done) that have no counterpart in the linear + * execute → review → merge → end pipeline graph. The IR edges describe the + * forward automation walk; the column adjacency describes legal *board* moves + * (drags, reopens, recovery), which is a strictly larger, partly-cyclic set. + * + * So per the plan's documented fallback we attach an explicit per-column + * `transitions` adjacency: + * - For the BUILT-IN default workflow we reproduce `VALID_TRANSITIONS` verbatim + * (keyed by the legacy column ids, which are exactly the default workflow's + * column ids — KTD-1). This is the parity contract the transition-parity + * suite machine-checks. + * - For CUSTOM workflows (no explicit adjacency authored yet — authoring lands + * with the editor in U10) we derive a linear forward+back adjacency from the + * declared column ORDER: each column may move to its neighbors (prev/next). + * This is a safe, predictable default that keeps every column reachable and + * never strands a card; richer custom adjacency is future work. + * + * The adjacency is intentionally a column→columns map computed once per IR; it + * is read-only and pure. + */ + +import { VALID_TRANSITIONS } from "./types.js"; +import type { Column } from "./types.js"; +import type { WorkflowIr, WorkflowIrV2 } from "./workflow-ir-types.js"; +import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js"; + +/** A column→allowed-target-columns adjacency map. */ +export type ColumnAdjacency = Map; + +/** True when the IR's columns are exactly the legacy default-workflow column ids + * (same set), i.e. this is the built-in default workflow (or an equivalent). */ +function isDefaultWorkflowColumns(ir: WorkflowIrV2): boolean { + const ids = ir.columns.map((c) => c.id); + if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false; + const set = new Set(ids); + return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id)); +} + +/** Build the verbatim `VALID_TRANSITIONS` adjacency keyed by column id. */ +function defaultWorkflowAdjacency(): ColumnAdjacency { + const adj: ColumnAdjacency = new Map(); + for (const [from, targets] of Object.entries(VALID_TRANSITIONS) as [Column, Column[]][]) { + adj.set(from, [...targets]); + } + return adj; +} + +/** Derive a neighbor (prev/next by declared order) adjacency for a custom + * workflow. Each column can move to the column before and after it in the + * authored order. Endpoints have a single neighbor. */ +function orderDerivedAdjacency(ir: WorkflowIrV2): ColumnAdjacency { + const adj: ColumnAdjacency = new Map(); + const ids = ir.columns.map((c) => c.id); + for (let i = 0; i < ids.length; i++) { + const targets: string[] = []; + if (i > 0) targets.push(ids[i - 1]); + if (i < ids.length - 1) targets.push(ids[i + 1]); + adj.set(ids[i], targets); + } + return adj; +} + +/** + * Resolve the full column adjacency for a workflow IR. The default workflow + * reproduces `VALID_TRANSITIONS` exactly; custom workflows use order-derived + * neighbor adjacency. + */ +export function resolveColumnAdjacency(ir: WorkflowIr): ColumnAdjacency { + // v1 IR is upgraded to v2 on parse, but accept either defensively. + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) { + // No columns (shouldn't happen post-parse) → empty adjacency. + return new Map(); + } + if (isDefaultWorkflowColumns(v2)) { + return defaultWorkflowAdjacency(); + } + return orderDerivedAdjacency(v2); +} + +/** + * The allowed target columns for a move out of `fromColumn` under this workflow. + * Returns an empty array when `fromColumn` is unknown to the workflow (callers + * should first check column existence to distinguish "unknown column" from "no + * legal targets"). + */ +export function resolveAllowedColumns(ir: WorkflowIr, fromColumn: string): string[] { + return resolveColumnAdjacency(ir).get(fromColumn) ?? []; +} + +/** True when `toColumn` is a defined column of the workflow. */ +export function workflowHasColumn(ir: WorkflowIr, columnId: string): boolean { + const v2 = ir as WorkflowIrV2; + return Array.isArray(v2.columns) && v2.columns.some((c) => c.id === columnId); +} diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 09e9b60a8a..8c4b40e8a6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -79,6 +79,10 @@ import type { TaskIdIntegrityReport, BranchGroup, BranchGroupPrState, + WorkflowFieldDefinition, + WorkflowFieldType, + WorkflowFieldOption, + WorkflowFieldRender, } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core"; @@ -533,6 +537,81 @@ export function moveTask( }); } +/** Resolved trait flags for a board column (subset the client cares about). */ +export interface BoardWorkflowColumnFlags { + countsTowardWip?: boolean; + complete?: boolean; + archived?: boolean; + hiddenFromBoard?: boolean; + hold?: boolean; + intake?: boolean; + mergeBlocker?: boolean; + humanReview?: boolean; + [key: string]: boolean | undefined; +} + +export interface BoardWorkflowColumn { + id: string; + name: string; + flags: BoardWorkflowColumnFlags; +} + +// WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender +// are re-exported from @fusion/core above (KTD-13/14). +export type { WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender }; + +export interface BoardWorkflowDefinition { + id: string; + name: string; + columns: BoardWorkflowColumn[]; + /** Custom field definitions declared by this workflow (U13/KTD-14). Absent on + * workflows with no fields, or from older servers. */ + fields?: WorkflowFieldDefinition[]; +} + +export interface BoardWorkflowsPayload { + flagEnabled: boolean; + defaultWorkflowId: string; + workflows: BoardWorkflowDefinition[]; + taskWorkflowIds: Record; +} + +/** A typed custom-field rejection surfaced by the PATCH endpoint (KTD-13). */ +export interface CustomFieldRejection { + code: "no-fields-defined" | "unknown-field" | "type-mismatch" | "enum-violation"; + fieldId: string; + detail: string; +} + +/** + * Patch a task's custom field values (U13/KTD-14). The server validates the + * patch against the task's workflow field schema and returns the updated task; + * a validation failure surfaces as a 400 carrying `{ fieldId, code, detail }`. + * A `null` value for a field deletes it. + */ +export function updateTaskCustomFields( + id: string, + customFields: Record, + projectId?: string, +): Promise { + return api(withProjectId(`/tasks/${id}/custom-fields`, projectId), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customFields }), + }); +} + +/** Fetch the multi-lane board metadata (U9). When the flag is OFF the server + * returns `{ flagEnabled: false }` and the board renders its legacy form. */ +export function fetchBoardWorkflows(projectId?: string): Promise { + return api(withProjectId("/tasks/board-workflows", projectId)); +} + +/** Manually promote a held card out of its hold column (U9). */ +export function promoteTask(id: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/promote`, projectId), { method: "POST" }); +} + /** * Soft-deletes a task by setting `deletedAt` server-side while preserving the row/artifacts, * and keeping the task ID reserved. @@ -4958,6 +5037,37 @@ export function fetchWorkflows(projectId?: string): Promise api(path)); } +/** A trait catalog entry as returned by GET /api/traits (U10). Mirrors the + * registry's TraitDefinition projection (flags + hook descriptors + schema). */ +export interface TraitCatalogEntry { + id: string; + name: string; + description?: string; + builtin: boolean; + flags: import("@fusion/core").TraitFlags; + hooks?: import("@fusion/core").TraitHookDescriptors; + configSchema?: import("@fusion/core").TraitConfigSchema; +} + +/** Fetch the trait catalog (built-ins + registered plugin traits) for the + * workflow editor's trait picker. Registry-backed, read-only, session-scoped. */ +export function fetchTraits(projectId?: string): Promise { + const path = withProjectId("/traits", projectId); + return dedupe(path, () => + api<{ traits: TraitCatalogEntry[] }>(path).then((res) => res.traits), + ); +} + +/** Fetch the step-parser id catalog (built-ins + registered plugin parsers) for + * the parse-steps node inspector (KTD-12). Registry-backed, read-only, + * session-scoped. Mirrors fetchTraits. */ +export function fetchStepParsers(projectId?: string): Promise { + const path = withProjectId("/step-parsers", projectId); + return dedupe(path, () => + api<{ parsers: Array<{ id: string }> }>(path).then((res) => res.parsers.map((p) => p.id)), + ); +} + /** Fetch a single workflow definition. */ export function fetchWorkflow(id: string, projectId?: string): Promise { return api(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId)); @@ -5011,8 +5121,18 @@ export function selectTaskWorkflow( taskId: string, workflowId: string | null, projectId?: string, -): Promise<{ workflowId: string | null; enabledWorkflowSteps: string[] }> { - return api<{ workflowId: string | null; enabledWorkflowSteps: string[] }>( +): Promise<{ + workflowId: string | null; + enabledWorkflowSteps: string[]; + // U5 (R20): present (flag ON) when the switch re-homed the card; `preserved` + // false means the card moved columns and the board needs a refresh. + reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string }; +}> { + return api<{ + workflowId: string | null; + enabledWorkflowSteps: string[]; + reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string }; + }>( withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId), { method: "PUT", diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 423c39df46..9b7ed08a90 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -2,11 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { Column } from "./Column"; +import { Lane } from "./Lane"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; -import { fetchWorkflowSteps, type ModelInfo } from "../api"; +import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api"; import { useBlockerFanout } from "../hooks/useBlockerFanout"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { subscribeSse } from "../sse-bus"; + +/** localStorage key for persisted lane collapse state (per project). */ +const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed"; interface BoardProps { tasks: Task[]; @@ -261,10 +266,243 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask }; }, []); + // ── U9 multi-lane board (flag-gated) ────────────────────────────────────── + // Fetch board-workflows metadata. When the flag is OFF the server returns + // { flagEnabled: false } and we render the legacy single-lane board below. + const [boardWorkflows, setBoardWorkflows] = useState(null); + const draggingTaskIdRef = useRef(null); + const [collapsedLanes, setCollapsedLanes] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string")); + } catch { + /* ignore corrupt persisted state */ + } + return new Set(); + }); + + // Fetch board workflow lanes for the project. Deliberately NOT keyed on + // `tasks` — that refetched on every SSE tick. Instead we refetch on project + // change and when the tab regains visibility/focus. A stale-response guard + // (monotonic sequence ref) drops out-of-order responses. + // A `workflow:updated` (and create/delete) SSE event now drives invalidation + // when a definition's lanes / column traits change. The visibility/focus + // refetch below is retained as a stopgap for missed events / reconnects. + const boardWorkflowsFetchSeqRef = useRef(0); + useEffect(() => { + const runFetch = () => { + const seq = ++boardWorkflowsFetchSeqRef.current; + fetchBoardWorkflows(projectId) + .then((payload) => { + if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); + }) + .catch(() => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + } + }); + }; + runFetch(); + const onVisible = () => { + if (typeof document === "undefined" || document.visibilityState === "visible") runFetch(); + }; + if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); + if (typeof window !== "undefined") window.addEventListener("focus", onVisible); + const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const unsubscribe = subscribeSse(`/api/events${query}`, { + events: { + "workflow:created": runFetch, + "workflow:updated": runFetch, + "workflow:deleted": runFetch, + }, + }); + return () => { + // Advance the seq so any in-flight response is dropped on cleanup. + boardWorkflowsFetchSeqRef.current++; + if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible); + if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); + unsubscribe(); + }; + }, [projectId]); + + const handleToggleLaneCollapse = useCallback((workflowId: string) => { + setCollapsedLanes((prev) => { + const next = new Set(prev); + if (next.has(workflowId)) next.delete(workflowId); + else next.add(workflowId); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next])); + } catch { + /* ignore quota / serialization errors */ + } + } + return next; + }); + }, []); + + const handlePromote = useCallback(async (taskId: string) => { + await promoteTask(taskId, projectId); + }, [projectId]); + + const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []); + + const flagOn = boardWorkflows?.flagEnabled === true; + + // Group visible tasks into lanes by resolved workflow (null → default lane). + const lanes = useMemo(() => { + if (!boardWorkflows || !flagOn) return []; + const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows; + const byId = new Map(workflows.map((w) => [w.id, w] as const)); + const tasksByWorkflow = new Map(); + for (const task of tasks) { + // Archived cards are excluded from lanes (archived columns are hidden). + if (task.column === "archived") continue; + const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId; + (tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task); + } + const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = []; + for (const [workflowId, laneTasks] of tasksByWorkflow) { + const workflow = byId.get(workflowId); + if (!workflow) continue; + if (laneTasks.length === 0) continue; // zero-card lanes hidden + result.push({ workflow, tasks: laneTasks }); + } + // Default lane first; then by workflow name for stable ordering. + result.sort((a, b) => { + if (a.workflow.id === defaultWorkflowId) return -1; + if (b.workflow.id === defaultWorkflowId) return 1; + return a.workflow.name.localeCompare(b.workflow.name); + }); + return result; + }, [boardWorkflows, flagOn, tasks]); + + // Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes + // when the board-workflows payload changes, not on every SSE task tick. + const cardDefsByWorkflow = useMemo(() => { + const map = new Map(); + if (!boardWorkflows) return map; + for (const wf of boardWorkflows.workflows) { + const cardDefs = (wf.fields ?? []).filter((f) => f.render?.placement === "card"); + if (cardDefs.length > 0) map.set(wf.id, cardDefs); + } + return map; + }, [boardWorkflows]); + + // Per-task card field defs (U13/KTD-14). Recomputes on task list changes but + // reuses the stable cardDefsByWorkflow map so the inner loop is cheap. + const taskCardFieldDefs = useMemo(() => { + const map = new Map(); + if (cardDefsByWorkflow.size === 0) return map; + if (!boardWorkflows) return map; + const { taskWorkflowIds, defaultWorkflowId } = boardWorkflows; + for (const task of tasks) { + const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId; + const defs = cardDefsByWorkflow.get(workflowId); + if (defs) map.set(task.id, defs); + } + return map; + }, [cardDefsByWorkflow, tasks, boardWorkflows]); + + // Drag pre-check (R17): adjacency + capacity from the lane's column metadata. + // Cross-lane drag → workflow-mismatch. Deterministic rejections return a + // messageKey (no-move); null = allowed. + const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => { + if (!boardWorkflows) return null; + const sourceTask = tasks.find((t) => t.id === taskId); + if (!sourceTask) return null; + const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId; + // Cross-lane drag never switches workflows (R17). + if (sourceWorkflowId !== laneWorkflowId) { + return "board.rejection.workflowMismatch"; + } + const workflow = boardWorkflows.workflows.find((w) => w.id === laneWorkflowId); + if (!workflow) return null; + const targetCol = workflow.columns.find((c) => c.id === targetColumnId); + if (!targetCol) return "board.rejection.unknownColumn"; + // Capacity pre-check: a wip-flagged column that is already full rejects. + if (targetCol.flags.countsTowardWip) { + const occupants = tasks.filter( + (t) => t.column === targetColumnId && (boardWorkflows.taskWorkflowIds[t.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId, + ).length; + // The default workflow's in-progress limit is maxConcurrent; custom limits + // are enforced authoritatively server-side (the 409 fallback still snaps back). + if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) { + return "board.rejection.capacityExhausted"; + } + } + return null; + }, [boardWorkflows, tasks, maxConcurrent]); + // FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`, // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. + if (flagOn) { + return ( +
{ + const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); + if (id) draggingTaskIdRef.current = id; + }} + onDragEnd={() => { + draggingTaskIdRef.current = null; + }} + > + {lanes.map(({ workflow, tasks: laneTasks }) => ( + + ))} +
+ ); + } + return ( <>
@@ -298,6 +536,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} + taskCardFieldDefs={taskCardFieldDefs} blockerFanoutMap={blockerFanoutMap} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} diff --git a/packages/dashboard/app/components/ChangesDiffModal.tsx b/packages/dashboard/app/components/ChangesDiffModal.tsx index 4ebcd553e2..e9147c1a85 100644 --- a/packages/dashboard/app/components/ChangesDiffModal.tsx +++ b/packages/dashboard/app/components/ChangesDiffModal.tsx @@ -11,7 +11,7 @@ import { RefreshCw, GitCommit, } from "lucide-react"; -import type { MergeDetails, Column } from "@fusion/core"; +import type { MergeDetails, ColumnId } from "@fusion/core"; import { highlightDiff } from "../utils/highlightDiff"; import "./TaskDiffShared.css"; import "./ChangesDiffModal.css"; @@ -31,7 +31,7 @@ interface ChangesDiffModalProps { files: NormalizedFile[]; stats: { filesChanged: number; additions: number; deletions: number }; mergeDetails?: MergeDetails; - column?: Column; + column?: ColumnId; onClose: () => void; onRefresh?: () => void; } diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index abcd3af535..309f251064 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -11,13 +11,80 @@ import { PluginSlot } from "./PluginSlot"; import { groupByWorktree } from "../utils/worktreeGrouping"; import type { ToastType } from "../hooks/useToast"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; -import type { ModelInfo } from "../api"; +import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; const PAGINATED_COLUMN_THRESHOLD = 100; const VISIBLE_TASKS_INITIAL = 50; const VISIBLE_TASKS_INCREMENT = 25; +/** Shape of a structured transition rejection carried in a 409's `details`. */ +interface TransitionRejectionDetail { + code: string; + messageKey: string; + retryable: boolean; +} + +/** + * Pull a typed transition rejection out of an `ApiRequestError`'s `details` + * (the structured 409 the move/promote endpoints emit under the workflowColumns + * flag). Returns null for any other error shape (legacy errors are unchanged). + */ +export function extractTransitionRejection(err: unknown): TransitionRejectionDetail | null { + const details = (err as { details?: Record } | null)?.details; + if (!details || typeof details !== "object") return null; + const { code, messageKey, retryable } = details as Record; + if (typeof code === "string" && typeof messageKey === "string") { + return { code, messageKey, retryable: retryable === true }; + } + return null; +} + +/** + * Resolve a rejection (by stable code, falling back to its messageKey) to + * user-facing copy. The static `t()` literals here are what the i18next + * extractor sees, so the `board.rejection.*` keys persist in the catalog and + * the surfaces show real copy rather than a raw key. The `messageKey` carried by + * the rejection is still honored as the lookup so a server-chosen non-default + * key resolves correctly. + */ +type TFn = (key: string, defaultValue: string) => string; +export function translateRejection(t: TFn, rejection: TransitionRejectionDetail): string { + switch (rejection.code) { + case "guard-rejected": + return t("board.rejection.guardRejected", "This move is not allowed by the workflow."); + case "capacity-exhausted": + return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."); + case "unknown-column": + return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."); + case "workflow-mismatch": + return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."); + case "merge-blocked": + return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."); + default: + return t(rejection.messageKey, rejection.messageKey); + } +} + +/** Translate a bare drag pre-check messageKey (R17 no-move) to copy. The same + * static literals as {@link translateRejection} so the extractor keeps them. */ +export function translateRejectionKey(t: TFn, messageKey: string): string { + switch (messageKey) { + case "board.rejection.guardRejected": + return t("board.rejection.guardRejected", "This move is not allowed by the workflow."); + case "board.rejection.capacityExhausted": + return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."); + case "board.rejection.unknownColumn": + return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."); + case "board.rejection.workflowMismatch": + return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."); + case "board.rejection.mergeBlocked": + return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."); + default: + return t(messageKey, messageKey); + } +} + interface ColumnProps { column: ColumnType; tasks: Task[]; @@ -73,24 +140,73 @@ interface ColumnProps { lastFetchTimeMs?: number; /** Lookup of workflow step IDs to display names, fetched once at board level. */ workflowStepNameLookup?: ReadonlyMap; + /** Per-task card-placed custom field definitions (U13/KTD-14). */ + taskCardFieldDefs?: ReadonlyMap; /** Precomputed blocker fanout keyed by blocker task ID. */ blockerFanoutMap?: ReadonlyMap; /** Whether GitHub CLI auth is available for creating PRs from task cards. */ prAuthAvailable?: boolean; + // ── U9 workflow-columns (flag-ON) additive props ───────────────────────── + /** True when the board is in multi-lane workflow mode (flag ON). Switches + * column behavior (label, bulk actions, archived detection) from legacy + * literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */ + workflowMode?: boolean; + /** Display name for this column, from the workflow definition. */ + columnDisplayName?: string; + /** Resolved trait flags for this column (workflow mode). */ + columnFlags?: BoardWorkflowColumnFlags; + /** Manually promote a held card out of this hold column (workflow mode). */ + onPromote?: (taskId: string) => Promise; + /** + * Pre-check whether a drop into THIS column is allowed for the dragged task. + * Returns null for "allowed", or an i18n messageKey for a deterministic + * rejection (guard/capacity/unknown-column/workflow-mismatch). When a + * rejection is returned, dragover is NOT prevented, so the card never renders + * in this column (no-move semantics, R17). The dragged task id is read from a + * board-level ref set on dragstart. + */ + canDropTask?: (taskId: string) => string | null; + /** Read the id of the task currently being dragged (board-level ref). */ + getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { const { t } = useTranslation("app"); + // Anchor the board.rejection.* catalog keys for the i18next extractor (it + // scopes `t` to the useTranslation binding, so the shared translateRejection + // helper's calls are not statically discovered). These resolve the same copy. + const rejectionCopy = useMemo(() => ({ + guardRejected: t("board.rejection.guardRejected", "This move is not allowed by the workflow."), + capacityExhausted: t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."), + unknownColumn: t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."), + workflowMismatch: t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."), + mergeBlocked: t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."), + promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."), + }), [t]); + void rejectionCopy; const [dragOver, setDragOver] = useState(false); const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isReplanning, setIsReplanning] = useState(false); const [isPausingAll, setIsPausingAll] = useState(false); const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false); + // Workflow mode: per-card promote in-flight ids + inline capacity feedback. + const [promotingIds, setPromotingIds] = useState>(() => new Set()); + const [inlineFeedback, setInlineFeedback] = useState(null); const menuRef = useRef(null); const countFlashing = useFlashOnIncrease(tasks.length); const { confirm } = useConfirm(); + // Clear the inline capacity-exhausted banner once the column's task list + // changes via SSE (e.g. an occupant moves out and capacity frees up). The + // banner reflects a point-in-time promote rejection; a changed roster means + // the stale constraint may no longer hold. Keyed on the task-id signature so + // it only fires on real membership changes, not every parent re-render. + const taskIdSignature = useMemo(() => tasks.map((task) => task.id).join(","), [tasks]); + useEffect(() => { + setInlineFeedback(null); + }, [taskIdSignature]); + // Close the column dropdown menu when the user clicks anywhere else. useEffect(() => { if (!isMenuOpen) return; @@ -110,34 +226,54 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, }; }, [isMenuOpen]); - // Archived column is collapsed by default - don't show drag state when collapsed - const isArchived = column === "archived"; + // Archived column is collapsed by default - don't show drag state when collapsed. + // Workflow mode keys off the resolved `archived` trait flag instead of the + // literal column id (R9). A hold-flagged column shows the promote affordance. + const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived"; + const isHoldColumn = workflowMode && Boolean(columnFlags?.hold); const isCollapsed = isArchived && collapsed; + // Legacy in-progress renders worktree groups (not paginated); in workflow + // mode there is no special-casing, so a processing column paginates normally. + const isLegacyInProgress = !workflowMode && column === "in-progress"; // When search is active, skip pagination so all matching tasks are visible - const shouldPaginate = !isArchived && !isSearchActive && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD; + const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD; useEffect(() => { setVisibleTaskCount((current) => { - if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { + if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { return VISIBLE_TASKS_INITIAL; } return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length); }); - }, [column, isArchived, tasks.length]); + }, [isLegacyInProgress, isArchived, tasks.length]); const handleDragOver = useCallback((e: React.DragEvent) => { // Don't allow dropping into archived column via drag-drop if (isArchived) return; + // Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT + // call preventDefault, so the browser refuses the drop and the card never + // renders in this column. A null result means the drop is allowed. + if (workflowMode && canDropTask && getDraggingTaskId) { + const draggingId = getDraggingTaskId(); + if (draggingId) { + const rejectionKey = canDropTask(draggingId); + if (rejectionKey) { + setInlineFeedback(translateRejectionKey(t, rejectionKey)); + return; // no preventDefault → no-move + } + } + } e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(true); - }, [isArchived]); + }, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]); const handleDragLeave = useCallback((e: React.DragEvent) => { const el = e.currentTarget as HTMLElement; if (!el.contains(e.relatedTarget as Node)) { setDragOver(false); + setInlineFeedback(null); } }, []); @@ -185,14 +321,52 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, await onMoveTask(taskId, column, moveOptions); } catch (err) { - addToast(getErrorMessage(err), "error"); + // Workflow mode (R17): a structured 409 carries a typed rejection. The + // optimistic move snaps back automatically (the next SSE/refresh restores + // the card's real column); surface the translated rejection messageKey. + const rejection = extractTransitionRejection(err); + if (rejection) { + addToast(translateRejection(t, rejection), "error"); + } else { + addToast(getErrorMessage(err), "error"); + } } - }, [addToast, allTasks, column, confirm, onMoveTask, tasks]); + }, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]); + const handlePromote = useCallback(async (taskId: string) => { + if (!onPromote) return; + setInlineFeedback(null); + setPromotingIds((prev) => { + const next = new Set(prev); + next.add(taskId); + return next; + }); + try { + await onPromote(taskId); + } catch (err) { + const rejection = extractTransitionRejection(err); + if (rejection) { + // Capacity-exhausted (and any rejection) shows INLINE column feedback, + // not a toast — so multiple holds can promote concurrently without spam. + setInlineFeedback(translateRejection(t, rejection)); + } else { + setInlineFeedback(getErrorMessage(err)); + } + } finally { + setPromotingIds((prev) => { + const next = new Set(prev); + next.delete(taskId); + return next; + }); + } + }, [onPromote, t]); + + // Worktree grouping is a legacy in-progress affordance; in workflow mode a + // custom processing column renders plain cards (KTD-11 keeps one-card-one-lane). const worktreeGroups = useMemo(() => { - if (column !== "in-progress") return []; + if (!isLegacyInProgress) return []; return groupByWorktree(tasks, tasks, maxConcurrent); - }, [column, tasks, maxConcurrent]); + }, [isLegacyInProgress, tasks, maxConcurrent]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; @@ -238,7 +412,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, [tasks], ); const pauseEligibleCount = pauseEligibleTasks.length; - const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review"; + // Bulk-action eligibility (R9): workflow mode keys off trait flags instead of + // the literal column ids. Todo-equivalent = hold/intake (replan affordance); + // processing = wip/countsTowardWip; review = mergeBlocker/humanReview. + const isTodoLikeColumn = workflowMode ? Boolean(columnFlags?.hold || columnFlags?.intake) : column === "todo"; + const isProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress"; + const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review"; + const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn; const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo; const handlePauseAll = useCallback(async () => { @@ -353,9 +533,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, >
-

{COLUMN_LABELS[column]}

+

{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}

{tasks.length} - {column === "in-review" && onToggleAutoMerge && ( + {(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && (
@@ -2009,8 +2015,8 @@ export function ListView({ {columnLabel(task.column)} @@ -2043,7 +2049,7 @@ export function ListView({ className="list-progress-fill" style={{ width: `${taskProgress.percent}%`, - backgroundColor: COLUMN_COLOR_MAP[task.column], + backgroundColor: columnColor(task.column), }} />
diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 42edbbfb4d..7be8c7f0cc 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -1447,3 +1447,53 @@ flex-wrap: wrap; } } + +/* Card-placed custom field badges (U13 / KTD-14). */ +.card-field-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin: 4px 0 2px; +} + +.card-field-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 7px; + border: 1px solid var(--border-color, #2a2d34); + border-radius: 999px; + background: var(--chip-bg, #1c1f26); + color: var(--text-muted, #b4b8c0); + font-size: 11px; + line-height: 1.5; + max-width: 16ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-field-badge--boolean { + background: var(--accent, #4f7cff); + border-color: var(--accent, #4f7cff); + color: #fff; +} + +.card-field-badge--multi { + gap: 3px; + max-width: none; +} + +.card-field-badge-token { + display: inline-flex; + align-items: center; + padding: 0 5px; + border-radius: 999px; + border: 1px solid var(--border-color, #2a2d34); + background: var(--chip-bg, #1c1f26); +} + +.card-field-badge--overflow { + font-weight: 600; +} diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 44078218eb..56037e61ce 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -1,9 +1,9 @@ import "./TaskCard.css"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; +import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react"; -import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, @@ -11,7 +11,7 @@ import { VALID_TRANSITIONS, getErrorMessage, } from "@fusion/core"; -import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api"; +import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api"; import { GitHubBadge } from "./GitHubBadge"; import { PrCreateModal } from "./PrCreateModal"; import { ProviderIcon } from "./ProviderIcon"; @@ -34,6 +34,15 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo import { useRetryWarning } from "../context/RetryWarningContext"; import { useColumnLabel } from "../i18n/labels"; +/** Per-branch progress snapshot (U13). Surfaced as an optional additive field + * on the task payload for the parallel-window badge (U9). */ +interface BranchProgressEntry { + branchId: string; + nodeId: string; + status: string; +} +type TaskWithBranchProgress = Task & { branchProgress?: BranchProgressEntry[] }; + // ── Mission title caching ─────────────────────────────────────────────────── const missionTitleCache = new Map(); @@ -135,7 +144,9 @@ function isAgentCreatedTask(task: Task): boolean { // ── Constants ─────────────────────────────────────────────────────────────── -const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); +// Issue 1403: widened to ColumnId so `.has(task.column)` accepts custom column ids +// (which are not members and correctly resolve to false). +const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]); @@ -149,7 +160,7 @@ const COLUMN_PROGRESS_COLOR_MAP: Record = { archived: "var(--text-muted)", }; -const TIME_INDICATOR_COLUMNS = new Set([ +const TIME_INDICATOR_COLUMNS = new Set([ "in-progress", "in-review", "done", @@ -288,6 +299,72 @@ export function formatElapsedDurationDone(elapsedMs: number): string { } +/** Max number of card-placed custom fields rendered before an overflow chip + * (KTD-14: "max 3 card fields rendered with a +N overflow indicator"). */ +const MAX_CARD_FIELDS = 3; + +/** Render a single card-placed custom field value as a badge/chip (U13/KTD-14). + * Returns null for empty/unset values so absent fields take no card space. */ +function renderCardFieldBadge( + field: WorkflowFieldDefinition, + value: unknown, +): ReactElement | null { + const colorOf = (v: string): string | undefined => field.options?.find((o) => o.value === v)?.color; + const labelOf = (v: string): string => field.options?.find((o) => o.value === v)?.label ?? v; + + if (field.type === "boolean") { + // Boolean true → labeled chip; false/unset → nothing. + if (value !== true) return null; + return ( + + {field.name} + + ); + } + if (field.type === "enum") { + if (typeof value !== "string" || value === "") return null; + const color = colorOf(value); + return ( + + {labelOf(value)} + + ); + } + if (field.type === "multi-enum") { + const arr = Array.isArray(value) ? (value as string[]) : []; + if (arr.length === 0) return null; + return ( + + {arr.map((v) => { + const color = colorOf(v); + return ( + + {labelOf(v)} + + ); + })} + + ); + } + // string / text / number / date / url → simple labeled chip. + if (value === undefined || value === null || value === "") return null; + const display = field.type === "date" && typeof value === "string" ? value.slice(0, 10) : String(value); + return ( + + {display} + + ); +} + interface TaskCardProps { task: Task; projectId?: string; @@ -327,6 +404,9 @@ interface TaskCardProps { prAuthAvailable?: boolean; /** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */ autoMergeEnabled?: boolean; + /** Card-placed custom field definitions for this task's workflow (U13/KTD-14). + * Empty/undefined → no field badges render (card byte-identical to today). */ + cardFieldDefs?: WorkflowFieldDefinition[]; } function getTaskPrimaryPrInfo(task: Pick): PrInfo | undefined { @@ -460,6 +540,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs && previous.prAuthAvailable === next.prAuthAvailable && previous.autoMergeEnabled === next.autoMergeEnabled && + previous.cardFieldDefs === next.cardFieldDefs && + (previous.cardFieldDefs == null && next.cardFieldDefs == null + ? true + : JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null)) && previous.onOpenDetail === next.onOpenDetail && previous.onOpenGroupModal === next.onOpenGroupModal && previous.addToast === next.addToast && @@ -484,6 +568,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.title === nextTask.title && previousTask.description === nextTask.description && previousTask.column === nextTask.column && + ((previousTask as TaskWithBranchProgress).branchProgress?.length ?? 0) === + ((nextTask as TaskWithBranchProgress).branchProgress?.length ?? 0) && previousTask.columnMovedAt === nextTask.columnMovedAt && previousTask.timedExecutionMs === nextTask.timedExecutionMs && previousTask.updatedAt === nextTask.updatedAt && @@ -571,6 +657,7 @@ function TaskCardComponent({ fanout, prAuthAvailable, autoMergeEnabled = false, + cardFieldDefs, }: TaskCardProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); @@ -1746,6 +1833,24 @@ function TaskCardComponent({ {t("tasks.stuck", "Stuck")} )} + {/* U13/U9: per-branch progress badges while the card is in a parallel + window. Reads an optional additive `branchProgress` field on the task + payload (server-persisted by U13); absent → nothing renders. */} + {Array.isArray((task as TaskWithBranchProgress).branchProgress) && + (task as TaskWithBranchProgress).branchProgress!.length > 0 && ( + + {t("tasks.branchProgress", "{{done}}/{{total}} branches", { + done: (task as TaskWithBranchProgress).branchProgress!.filter( + (b) => b.status === "completed", + ).length, + total: (task as TaskWithBranchProgress).branchProgress!.length, + })} + + )} {showStalledReview && stalledReview && ( {truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id} + {(() => { + // Card-placed custom field badges (U13/KTD-14). Bounded to MAX_CARD_FIELDS + // with a "+N" overflow chip. Nothing renders when no card fields are + // defined or all values are empty — card stays byte-identical to today. + const cardDefs = (cardFieldDefs ?? []).filter((f) => f.render?.placement === "card"); + if (cardDefs.length === 0) return null; + const values = task.customFields ?? {}; + const badges = cardDefs + .map((f) => renderCardFieldBadge(f, values[f.id])) + .filter((b): b is ReactElement => b !== null); + if (badges.length === 0) return null; + const shown = badges.slice(0, MAX_CARD_FIELDS); + const overflow = badges.length - shown.length; + return ( +
+ {shown} + {overflow > 0 ? ( + + +{overflow} + + ) : null} +
+ ); + })()} {hasBranchMetadata && (
{branchMetadata.branch && ( @@ -1973,7 +2102,9 @@ function TaskCardComponent({ className="card-progress-fill" style={{ width: `${progressPercent}%`, - backgroundColor: COLUMN_PROGRESS_COLOR_MAP[task.column], + // Issue 1403: custom columns have no legacy progress color → fall back to accent. + backgroundColor: + (COLUMN_PROGRESS_COLOR_MAP as Record)[task.column] ?? "var(--accent)", }} />
diff --git a/packages/dashboard/app/components/TaskChangesTab.tsx b/packages/dashboard/app/components/TaskChangesTab.tsx index 784b2bb281..ec117c7cfb 100644 --- a/packages/dashboard/app/components/TaskChangesTab.tsx +++ b/packages/dashboard/app/components/TaskChangesTab.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react"; -import type { MergeDetails, Column } from "@fusion/core"; +import type { MergeDetails, ColumnId } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchTaskDiff, @@ -16,7 +16,7 @@ interface TaskChangesTabProps { taskId: string; worktree?: string; projectId?: string; - column?: Column; + column?: ColumnId; mergeDetails?: MergeDetails; /** * Files modified by the task during execution, captured from the worktree. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index f92b153f67..0d32ed7567 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -9,19 +9,22 @@ import { useColumnLabel } from "../i18n/labels"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, TASK_PRIORITIES, VALID_TRANSITIONS, + isColumn, getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, } from "@fusion/core"; -import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus } from "../api"; -import type { RecoverBranchBindingOutcome } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api"; +import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; +import { ApiRequestError } from "../api"; +import { TaskFieldsSection } from "./TaskFieldsSection"; import type { ToastType } from "../hooks/useToast"; import { useAgentLogs } from "../hooks/useAgentLogs"; import { useConfirm } from "../hooks/useConfirm"; @@ -305,6 +308,11 @@ export interface TaskDetailModalProps { initialTab?: TabId; /** Mobile-only header affordance mode. */ mobileHeaderMode?: "close" | "back"; + /** Pre-resolved workflow field defs for this task's workflow (U13/KTD-14). + * When provided (e.g. threaded from a Board that already holds the payload) + * the modal skips its own board-workflows fetch entirely. Falls back to the + * self-fetch when absent (e.g. modal opened from non-board contexts). */ + workflowFieldDefs?: WorkflowFieldDefinition[] | null; } export type TaskDetailContentProps = Omit & { @@ -456,8 +464,10 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt const DESCRIPTION_TRUNCATE_LENGTH = 200; -const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); -const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); +// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids +// (non-members correctly resolve to false → not editable). +const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); +const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); export function TaskDetailContent({ task, @@ -478,6 +488,7 @@ export function TaskDetailContent({ mobileHeaderMode = "close", embedded = false, onRequestClose, + workflowFieldDefs: workflowFieldDefsProp, }: TaskDetailContentProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); @@ -602,6 +613,69 @@ export function TaskDetailContent({ const [showRefineModal, setShowRefineModal] = useState(false); const [prCreateOpen, setPrCreateOpen] = useState(false); + // Custom field definitions (U13/KTD-14). Resolved for this task's workflow + // from the board-workflows payload; absent when the workflow declares none, + // in which case the fields section renders nothing (today's UI byte-identical). + // When `workflowFieldDefsProp` is provided by the caller (e.g. the Board + // already holds the payload) we skip the self-fetch entirely. + const [customFieldDefs, setCustomFieldDefs] = useState( + workflowFieldDefsProp !== undefined ? (workflowFieldDefsProp ?? null) : null, + ); + const [customFieldValues, setCustomFieldValues] = useState>(task.customFields ?? {}); + const [customFieldError, setCustomFieldError] = useState(null); + + // Keep local field values in sync when the task prop changes (SSE refresh). + useEffect(() => { + setCustomFieldValues(task.customFields ?? {}); + }, [task.id, task.customFields]); + + // Resolve this task's workflow field definitions once per task. Skipped when + // the caller supplies `workflowFieldDefs` directly (Board context). Best-effort: + // a failed fetch (or flag-OFF empty payload) leaves defs null → no section. + useEffect(() => { + if (workflowFieldDefsProp !== undefined) { + // Prop-driven path: keep in sync if the prop changes (task switch etc.). + setCustomFieldDefs(workflowFieldDefsProp ?? null); + return; + } + let cancelled = false; + void fetchBoardWorkflows(projectId) + .then((payload) => { + if (cancelled) return; + const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId; + const workflow = payload.workflows.find((w) => w.id === workflowId); + setCustomFieldDefs(workflow?.fields ?? null); + }) + .catch(() => { + if (!cancelled) setCustomFieldDefs(null); + }); + return () => { + cancelled = true; + }; + }, [task.id, projectId, workflowFieldDefsProp]); + + const handleSaveCustomFields = useCallback( + async (patch: Record) => { + setCustomFieldError(null); + try { + const updated = await updateTaskCustomFields(task.id, patch, projectId); + setCustomFieldValues(updated.customFields ?? {}); + onTaskUpdated?.(updated); + } catch (err) { + if (err instanceof ApiRequestError && err.details && typeof err.details.fieldId === "string") { + setCustomFieldError({ + code: (err.details.code as CustomFieldRejection["code"]) ?? "type-mismatch", + fieldId: err.details.fieldId, + detail: typeof err.details.detail === "string" ? err.details.detail : err.message, + }); + return; + } + addToast(getErrorMessage(err) || t("taskFields.saveFailed", "Failed to save field"), "error"); + } + }, + [task.id, projectId, onTaskUpdated, addToast, t], + ); + useEffect(() => { if (activeTab !== "logs" || logSubview !== "activity") { setHighlightStallCode(null); @@ -1971,6 +2045,18 @@ export function TaskDetailContent({ } }, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]); + // U5 (R20): a workflow switch re-homed the card to a new column. Refetch the + // task and push it up so the board reflects the move before the SSE catch-up. + const handleWorkflowReconciled = useCallback(async () => { + try { + const detail = await fetchTaskDetail(task.id, projectId); + setFullDetail(detail); + onTaskUpdated?.(detail); + } catch { + // Best-effort refresh; the SSE stream will catch the board up regardless. + } + }, [task.id, projectId, onTaskUpdated]); + const loadAgents = useCallback(async () => { setAgentsLoading(true); try { @@ -2217,7 +2303,9 @@ export function TaskDetailContent({ return providers; }, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]); - const transitions = VALID_TRANSITIONS[task.column] || []; + // #1403: legacy transitions only exist for legacy columns; a custom column id + // has no VALID_TRANSITIONS row, so the move menu shows no legacy targets. + const transitions: Column[] = isColumn(task.column) ? [...VALID_TRANSITIONS[task.column]] : []; const inReviewMoveTransitions: Column[] = ["todo", "in-progress"]; const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions; const primaryMoveTransition = moveTransitions[0]; @@ -2468,6 +2556,15 @@ export function TaskDetailContent({ ); })()} + {customFieldDefs && customFieldDefs.length > 0 ? ( + + ) : null} {showNearDuplicateWarning && (
@@ -2761,6 +2858,7 @@ export function TaskDetailContent({ && task.status !== "awaiting-cli-approval" } onWorkflowStepsChange={handleWorkflowStepsChange} + onWorkflowReconciled={handleWorkflowReconciled} taskStatus={task.status} taskPausedReason={task.pausedReason} /> diff --git a/packages/dashboard/app/components/TaskFieldsSection.css b/packages/dashboard/app/components/TaskFieldsSection.css new file mode 100644 index 0000000000..95181aef57 --- /dev/null +++ b/packages/dashboard/app/components/TaskFieldsSection.css @@ -0,0 +1,214 @@ +/* Schema-driven custom-field form section (U13 / KTD-14). */ + +.task-fields-section { + display: flex; + flex-direction: column; + gap: 12px; + margin: 12px 0; +} + +.task-field-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.task-field-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted, #8a8f98); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.task-field-required { + color: var(--accent-danger, #e5484d); +} + +.task-field-control { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.task-field-input, +.task-field-textarea, +.task-field-select { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border: 1px solid var(--border-color, #2a2d34); + border-radius: 6px; + background: var(--input-bg, #16181d); + color: var(--text-primary, #e6e6e6); + font-size: 13px; + font-family: inherit; +} + +.task-field-textarea { + resize: vertical; + min-height: 56px; +} + +.task-field-input:disabled, +.task-field-textarea:disabled, +.task-field-select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Chips (enum single + multi-enum) */ +.task-field-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.task-field-chip { + padding: 3px 10px; + border: 1px solid var(--border-color, #2a2d34); + border-radius: 999px; + background: var(--chip-bg, #1c1f26); + color: var(--text-muted, #b4b8c0); + font-size: 12px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.task-field-chip:hover:not(:disabled) { + border-color: var(--accent, #4f7cff); +} + +.task-field-chip.is-active { + background: var(--accent, #4f7cff); + border-color: var(--accent, #4f7cff); + color: #fff; +} + +.task-field-chip:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Radio group */ +.task-field-radio-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.task-field-radio { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary, #e6e6e6); + cursor: pointer; +} + +/* Boolean toggle */ +.task-field-toggle { + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.task-field-toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.task-field-toggle-track { + display: inline-block; + width: 34px; + height: 18px; + border-radius: 999px; + background: var(--border-color, #2a2d34); + position: relative; + transition: background 0.15s ease; +} + +.task-field-toggle-track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--card); + transition: transform 0.15s ease; +} + +.task-field-toggle input:checked + .task-field-toggle-track { + background: var(--accent, #4f7cff); +} + +.task-field-toggle input:checked + .task-field-toggle-track::after { + transform: translateX(16px); +} + +.task-field-toggle input:disabled + .task-field-toggle-track { + opacity: 0.6; +} + +/* Inline validation error */ +.task-field-error { + font-size: 12px; + color: var(--accent-danger, #e5484d); +} + +.task-field-row.has-error .task-field-input, +.task-field-row.has-error .task-field-textarea, +.task-field-row.has-error .task-field-select { + border-color: var(--accent-danger, #e5484d); +} + +/* Collapsible detail-section group */ +.task-fields-group, +.task-fields-orphaned { + border-top: 1px solid var(--border-color, #2a2d34); + padding-top: 8px; +} + +.task-fields-group-header, +.task-fields-orphaned-header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 0; + background: none; + border: none; + color: var(--text-muted, #8a8f98); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + cursor: pointer; +} + +.task-fields-group-body, +.task-fields-orphaned-body { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.task-fields-orphaned-count { + margin-left: auto; + background: var(--chip-bg, #1c1f26); + border-radius: 999px; + padding: 0 8px; + font-size: 11px; +} + +.task-field-orphaned-value { + font-size: 13px; + color: var(--text-muted, #b4b8c0); + word-break: break-word; +} diff --git a/packages/dashboard/app/components/TaskFieldsSection.tsx b/packages/dashboard/app/components/TaskFieldsSection.tsx new file mode 100644 index 0000000000..db5eaa730c --- /dev/null +++ b/packages/dashboard/app/components/TaskFieldsSection.tsx @@ -0,0 +1,436 @@ +/** + * Schema-driven custom-field form section (U13 / KTD-14). + * + * Renders a task's workflow-defined custom fields ({@link WorkflowFieldDefinition}) + * as editable widgets, grouped by `render.placement`: + * - `detail` (and the default when unset) → inline, near the description. + * - `detail-section` → inside a collapsible group. + * Card-placed fields (`placement: "card"`) are intentionally NOT rendered here — + * those surface as badges on {@link TaskCard}. + * + * Widget selection (per `type` + optional `render.widget`): + * - enum → select (default) | radio | chips (single-select) + * - multi-enum → chips (multi-select) + * - boolean → toggle + * - date → date input + * - url/number → validated + * - string → text input + * - text → textarea + * + * Editing is per-field, save-on-commit (blur for inputs, change for + * toggles/selects/chips/radio). Each save calls `onSave({ [fieldId]: value })`; + * on a 400 the caller surfaces the typed rejection through `error`, which this + * component renders inline beneath the offending field. + * + * Orphaned values — keys in `customFields` with no matching definition — render + * read-only under a collapsed "Orphaned fields" disclosure (never destroyed, + * KTD-13). + * + * Zero field definitions AND zero orphaned values → the component renders + * nothing (null), so a task on a field-less workflow is byte-identical to + * today's UI (snapshot-guarded by the test suite). + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronRight, ChevronDown } from "lucide-react"; +import type { + WorkflowFieldDefinition, + WorkflowFieldOption, + CustomFieldRejection, +} from "../api"; +import "./TaskFieldsSection.css"; + +export interface TaskFieldsSectionProps { + /** The task's workflow field definitions (from board-workflows payload). */ + fieldDefs: WorkflowFieldDefinition[]; + /** Current custom field values, keyed by field id. */ + customFields: Record; + /** + * Persist a single-field patch. Resolves on success; the caller is expected + * to throw / reject with the server's typed rejection so it can flow into + * `error`. May be omitted to render read-only (e.g. archived tasks). + */ + onSave?: (patch: Record) => Promise; + /** + * The most recent typed rejection from a failed save (400), surfaced inline + * beneath the matching field. Cleared by the caller on a successful save. + */ + error?: CustomFieldRejection | null; + /** When true, fields render read-only (no edit affordances). */ + readOnly?: boolean; +} + +/** Resolve the effective widget for a field, applying the per-type default. */ +function resolveWidget(field: WorkflowFieldDefinition): NonNullable["widget"] { + const explicit = field.render?.widget; + if (explicit) return explicit; + switch (field.type) { + case "enum": + return "select"; + case "multi-enum": + return "chips"; + case "boolean": + return "toggle"; + case "text": + return "textarea"; + default: + return "input"; + } +} + +interface FieldRowProps { + field: WorkflowFieldDefinition; + value: unknown; + onSave?: (patch: Record) => Promise; + error?: CustomFieldRejection | null; + readOnly: boolean; +} + +function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) { + const { t } = useTranslation("app"); + const widget = resolveWidget(field); + const fieldError = error && error.fieldId === field.id ? error : null; + const disabled = readOnly || !onSave; + + // Serialize per-field saves: rapid chip/toggle/blur edits to the same field + // would otherwise fire overlapping PATCHes whose responses can resolve out of + // order, letting an older request clobber a newer selection. We chain each + // save onto the previous one for this field so they apply in click order. + const saveTailRef = useRef>(Promise.resolve()); + const commit = useCallback( + (next: unknown) => { + if (!onSave) return; + const run = () => onSave({ [field.id]: next }); + // Run after any in-flight save for this field, regardless of its outcome, + // so a rejected save doesn't permanently break the chain. The tail is kept + // settled-always (.catch) so its own rejection never floats unhandled and + // never blocks the next queued save — the caller surfaces failures via + // `error`, so we intentionally swallow here for ordering purposes only. + const prev = saveTailRef.current; + saveTailRef.current = prev.then(run, run).catch(() => {}); + }, + [onSave, field.id], + ); + + const labelId = `task-field-label-${field.id}`; + const controlId = `task-field-${field.id}`; + + // Prop-derived string value for the uncontrolled-style inputs (date / text / + // string / number / url). These were previously rendered with `defaultValue`, + // which only seeds on mount — so an external refresh of `customFields` (SSE or + // a save round-trip) left the DOM showing a stale value, and a later blur would + // commit that stale value back over the refreshed one. We make them controlled + // and re-sync to the latest prop whenever it changes. + const propTextValue = + field.type === "date" + ? typeof value === "string" + ? value.slice(0, 10) + : "" + : field.type === "number" + ? typeof value === "number" + ? String(value) + : "" + : typeof value === "string" + ? value + : ""; + const [localValue, setLocalValue] = useState(propTextValue); + useEffect(() => { + setLocalValue(propTextValue); + }, [propTextValue]); + + const renderControl = () => { + // enum → select / radio / chips (single) + if (field.type === "enum") { + const current = typeof value === "string" ? value : ""; + if (widget === "radio") { + return ( +
+ {(field.options ?? []).map((opt: WorkflowFieldOption) => ( + + ))} +
+ ); + } + if (widget === "chips") { + return ( +
+ {(field.options ?? []).map((opt) => { + const active = current === opt.value; + return ( + + ); + })} +
+ ); + } + // default: select + return ( + + ); + } + + // multi-enum → chips (multi-select) + if (field.type === "multi-enum") { + const current = Array.isArray(value) ? (value as string[]) : []; + return ( +
+ {(field.options ?? []).map((opt) => { + const active = current.includes(opt.value); + return ( + + ); + })} +
+ ); + } + + // boolean → toggle + if (field.type === "boolean") { + const checked = value === true; + return ( + + ); + } + + // date → date input + if (field.type === "date") { + return ( + setLocalValue(e.target.value)} + onBlur={(e) => { + const next = e.target.value; + if (next === propTextValue) return; + commit(next === "" ? null : next); + }} + /> + ); + } + + // text → textarea + if (field.type === "text") { + return ( +