Files
fusion/packages/core/src/workflow-optional-steps.ts
gsxdsm 83209e64dc fix(workflows): align stages with board columns (#2378)
## Summary

The Coding (Ideas) workflow now behaves like the board it presents:
Ideas stays inert, Todo owns planning and plan review, In progress owns
implementation, and In review owns code review and merge. The restored
preset is intentionally limited to that five-stage path, while the
existing Coding workflow remains unchanged.

Workflow execution now suspends at Todo→In progress instead of running
the implementation node early. A durable, single-owner continuation
records the exact resume node and survives process restarts; the
scheduler remains the only component allowed to admit the task into WIP.
Disabled optional review groups traverse the same boundary without
invoking a reviewer, avoiding the prior stuck-task behavior.

Workflow validation also rejects capacity holds with no reachable WIP
destination, so deterministic lifecycle deadlocks fail at authoring time
rather than after a task is running.

Session-settled decisions carried from planning: columns are execution
invariants, scheduler-owned WIP admission is preserved, the existing
Coding (Ideas) preset is restored and simplified, and invalid release
topology is rejected (user-approved).

## Validation

- `pnpm lint`
- `pnpm verify:fast`
- `pnpm test:gate` (296 engine, 128 PostgreSQL core, and 63 CI-shape
tests)
- Focused workflow lifecycle tests (106 assertions)
- PostgreSQL regression coverage proves atomic continuation replacement
and database rejection of a second active owner


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added durable, resumable workflow execution across capacity boundaries
(including explicit suspend/resume at the correct node).
* Introduced Todo “plan review” workflow continuations and automated
planning/capacity draining.
* Restored Coding (Ideas) as a selectable built-in and updated its lane
placement; improved optional-step group enablement support.
* **Bug Fixes**
  * User moves back to Todo now cancels active workflow continuations.
* Rejected workflow boundary transitions now surface as errors (instead
of silently continuing).
* Workflows with undriveable capacity-hold configurations are now
rejected.
* **Tests / Data**
* Expanded coverage for workflow suspension, continuations, and
continuation replacement; updated database schema to persist
continuation metadata and enforce single active continuation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 12:17:47 -07:00

149 lines
7.1 KiB
TypeScript

import type {
WorkflowIr,
WorkflowIrNode,
WorkflowOptionalGroupConfig,
} from "./workflow-ir-types.js";
import type { WorkflowStepTemplate } from "./types.js";
export interface ResolvedWorkflowOptionalStep {
templateId: string;
name: string;
description: string;
icon?: string;
phase: NonNullable<WorkflowStepTemplate["phase"]>;
defaultOn: boolean;
}
/** Resolve one optional group's effective state consistently across runtime and
* readiness gates. An explicit list (including `[]`) is authoritative; only a
* missing list falls back to the workflow-authored default.
* FNXC:WorkflowOptionalSteps 2026-07-21-11:51: Persisted explicit selections,
* including an empty list, must override workflow defaults across all gates. */
export function isWorkflowOptionalGroupEnabled(
enabledWorkflowSteps: readonly string[] | undefined,
groupId: string,
defaultOn = false,
): boolean {
return Array.isArray(enabledWorkflowSteps)
? enabledWorkflowSteps.includes(groupId)
: defaultOn;
}
/*
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep` type + `optionalSteps` IR field are now REMOVED (FNXC:WorkflowOptionalGroup 2026-06-21-18:00); a legacy persisted `optionalSteps` key on an old v2 row is tolerated/ignored at parse.
KEYING: the resolved entry is keyed by the group node `id`. The output field is still named `templateId` (not renamed) so the four consuming UI surfaces — inline quick-create card, New Task modal/TaskForm, task-detail Workflow tab, and the optional-steps dropdown — keep reading the same shape unchanged; they now toggle group ids into `enabledWorkflowSteps` instead of template ids. Renaming/recreating a group resets per-task state, identical to the prior `templateId` keying.
Display metadata: `name` comes from `config.name` (falling back to the node id), `defaultOn` from `config.defaultOn ?? false`, and `phase` from `config.phase ?? "pre-merge"`. `description` remains "" because optional-group nodes do not carry display copy for it.
*/
function isOptionalGroupNode(
node: WorkflowIrNode,
): node is WorkflowIrNode & { config: WorkflowOptionalGroupConfig } {
return node.kind === "optional-group";
}
function computeNodeExecutionRanks(ir: WorkflowIr): Map<string, number> {
const ranks = new Map<string, number>();
if (ir.version !== "v2" || !Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) {
return ranks;
}
const outgoing = new Map<string, string[]>();
for (const edge of ir.edges) {
if (!outgoing.has(edge.from)) outgoing.set(edge.from, []);
outgoing.get(edge.from)?.push(edge.to);
}
const queue: Array<{ id: string; rank: number }> = [];
for (const node of ir.nodes) {
if (node.kind === "start" || node.id === "start") {
queue.push({ id: node.id, rank: 0 });
}
}
while (queue.length > 0) {
const next = queue.shift();
if (!next) continue;
const current = ranks.get(next.id);
if (current !== undefined && current <= next.rank) continue;
ranks.set(next.id, next.rank);
for (const target of outgoing.get(next.id) ?? []) {
queue.push({ id: target, rank: next.rank + 1 });
}
}
return ranks;
}
/**
* Resolve a workflow's `optional-group` nodes into per-task toggle display
* metadata. Each enabled group's node id is what a task stores in
* `enabledWorkflowSteps`; this resolver advertises which groups a task may
* toggle plus their seed default.
*
* Source: v2 `ir.nodes` where `kind === "optional-group"` (NOT the legacy
* `ir.optionalSteps` declaration). Non-v2 graphs and graphs without any
* optional-group node resolve to `[]`. A group with a missing or partial config
* still resolves to a usable entry — `name` falls back to the node id and
* `defaultOn` to false — rather than being dropped, so a stale/partial node never
* silently disappears from the toggle UI or breaks workflow loading.
*
* `pluginTemplates` is accepted for signature compatibility with the prior
* template-backed resolver; group nodes are self-describing, so it is currently
* unused.
*/
export function resolveWorkflowOptionalSteps(
ir: WorkflowIr,
_pluginTemplates: WorkflowStepTemplate[] = [],
): ResolvedWorkflowOptionalStep[] {
if (ir.version !== "v2" || !Array.isArray(ir.nodes)) return [];
const ranks = computeNodeExecutionRanks(ir);
const resolved: Array<ResolvedWorkflowOptionalStep & { rank: number; nodeIndex: number }> = [];
for (const [nodeIndex, node] of ir.nodes.entries()) {
if (!isOptionalGroupNode(node)) continue;
const config = (node.config ?? {}) as Partial<WorkflowOptionalGroupConfig>;
resolved.push({
// Keyed by the group node id (documented above); field name preserved.
templateId: node.id,
name: typeof config.name === "string" && config.name.trim() ? config.name : node.id,
description: "",
/*
FNXC:WorkflowOptionalSteps 2026-06-29-12:47:
Post-merge verification is now a graph-native optional group. Task creation and detail surfaces need the resolver to preserve `config.phase` so post-merge toggles do not look like pre-merge gates.
*/
phase: config.phase === "post-merge" ? "post-merge" : "pre-merge",
defaultOn: config.defaultOn === true,
/*
FNXC:WorkflowDefinitionSteps 2026-06-29-00:41:
Definition/task creation surfaces must order optional groups by graph execution position, not raw node-array order. Derived built-ins can insert Plan Review between planning and parse while appending its node object, and operators still need the step list to show Plan Review before execution.
*/
rank: ranks.get(node.id) ?? Number.MAX_SAFE_INTEGER,
nodeIndex,
});
}
return resolved
.sort((a, b) => a.rank - b.rank || a.nodeIndex - b.nodeIndex)
.map(({ rank: _rank, nodeIndex: _nodeIndex, ...step }) => step);
}
/**
* Ids of `optional-group` nodes whose effective `defaultOn` is true. Used to
* seed a new task's `enabledWorkflowSteps` at creation, mirroring the prior
* `optionalStep.defaultOn ?? false` precedence (U3, R3). Defensive: non-v2
* graphs and graphs without optional groups yield `[]`.
*/
export function resolveDefaultOnOptionalGroupIds(ir: WorkflowIr): string[] {
return resolveWorkflowOptionalSteps(ir)
.filter((step) => step.defaultOn)
.map((step) => step.templateId);
}
/*
FNXC:WorkflowOptionalGroup 2026-06-21-16:30:
Every optional-group node id in a workflow, regardless of `defaultOn`. These ids are executor toggle keys (the per-task `enabledWorkflowSteps` set), NOT legacy `WorkflowStep` template ids. A built-in group id (e.g. "browser-verification") is passed through `resolveEnabledWorkflowSteps` untouched. (Historically it could collide with an id in the now-deleted built-in step-template catalog, which would wrongly materialize it into a step row the executor never matched; U6 removed that catalog + the materializer, so resolution is a pure identity-stable pass-through.)
*/
export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] {
return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId);
}