refactor(cutover 3/3): dashboard + changesets — IR-driven lifecycle cutover (#2335)
Completes the IR-driven lifecycle cutover: **the workflow IR becomes the
single source of truth for task lifecycle.** Node column assignments
move cards at runtime, every lifecycle predicate re-keys on column
traits instead of literal column ids, and the graph exclusively owns
review gates.
Plan (the spec for this work):
[`docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md`](docs/plans/2026-07-18-001-refactor-ir-driven-lifecycle-cutover-plan.md)
## What changed
**IR as runtime authority (R1, R2).** Graph traversal crossing a node
column boundary moves the card through the store's trait-hook `moveTask`
path, attributed `workflowMoveSource: "workflow-graph"` and emitting
`task:column-transition`. This replaces the executor's hardcoded
`moveTask(id, "in-review")` merge boundary and its handoff-invariant
allowlist. Scheduler, hold/release, self-healing, merger and
finalization now key on column traits
(`intake`/`hold`/`wip`/`merge-blocker`/`human-review`/`merge`/`complete`/`archived`/`timing`/`abort-on-exit`/`reset-on-entry`/`stall-detection`),
with rebound targets resolved per KTD-10.
**Single ownership of review gates (R4, R5).** Triage's out-of-graph
Plan Review gate is deleted; the graph is the sole author. `pending`
step results are CAS-claimed leases with owner and staleness floor
(KTD-4), so a crash/restart re-entry can no longer dispatch a second
reviewer and silently discard the losing verdict.
**Graph ownership is unconditional (R9).** The legacy execute fallback
is gone: `maybeExecuteWorkflowGraph` is now `executeWorkflowGraph`
returning `void`, `graphCompletion` is a required parameter, and a store
that cannot resolve a workflow fails closed rather than silently running
nothing. Also deleted, with a tombstone ratchet: `fn_review_step` and
its RETHINK/session-rewind machinery, `workflow-cutover.ts`,
`workflow-authoritative-driver.ts`, `workflow-parity-observer.ts`, and
the `graphCompletionInterceptors` map.
**`reviewLevel` becomes a creation-time preset (R6)** writing
`enabledWorkflowSteps`, with zero runtime reads.
**Upgrade path (R10).** Migration 0026 adds the durable per-node-entry
IR pin (KTD-3) and the one-time adoption stamp (KTD-8);
`planLegacyAdoption` is the single shared decision run by both the
startup sweep and the store-open reconcile, so pre-cutover rows are
adopted instead of freezing. A stale-binary guard refuses to open a
database migrated by a newer binary.
**Operator surfaces (R2, R11).** Four places still closed the column
set: the dashboard coerced every ingested task's column through the
legacy six-id enum (a card in a custom `Merging` column rendered in
**Triage**), `POST /tasks/:id/move` answered 400 for any
workflow-defined column, retry/reset/re-engage/unassign/spec-revise used
hardcoded move targets, and GitHub issue open/closed mapping
literal-compared `done`/`archived`. All now resolve from the task's
workflow by trait, each with a legacy fallback so `builtin:coding` is
byte-identical.
## Evidence
`builtin:coding` keeps its column ids and observable behavior
byte-compatible (R8, KTD-7), pinned by a characterization oracle. A new
**6-column benchmark acceptance suite** drives a user-authored workflow
— `Ideas → Todo → In-progress → In-review → Merging → Done` — asserting
the ordered transition trail, single-mover at the hold→wip seam (KTD-2),
column-role purity (R12), bounded review cycles from workflow config,
and park-in-place on failure (R3). The same fixture is proven
**editor-buildable** through the real save-validation path, plus
negative cases.
Verified locally on this branch, post-rebase:
- `pnpm test:gate` — green (engine-core 294/294, pg-gate 126/126,
ci-workflow 63/63)
- characterization oracle 59/59, tombstones 5/5, 6-column benchmark
11/11
- `tsc --noEmit` clean for `@fusion/core`, `@fusion/engine`,
`@fusion/dashboard` (both `tsconfig.json` and `tsconfig.app.json`)
## Known reds
- **`executor-task-done-invariant` → "moves a cleanly completed task to
in-review via the merge-node boundary"** — red on this branch. A
real-Postgres test whose graph re-entry rebounds the card to
`in-progress` after `execute()` returns. Not in the merge gate, so it
does not gate CI. Honest status: I could **not** verify it green on
pristine `main` — running main's tests in this worktree reuses built
artifacts and produced obviously polluted results, so I am not claiming
"pre-existing". It needs its own look.
- **`html2canvas` / FN-8309 — fixed here by deleting dead code.**
`packages/dashboard/app/utils/capture-screenshot.ts` imported
`html2canvas`, which is not a dependency of `@fusion/dashboard` and is
**not in `pnpm-lock.yaml` at all**, so it had never compiled in CI. The
file had **zero importers**. Main never caught it because PR Checks runs
only on pull requests (main's last PR Checks run was in June) while
main's own pushes run just the non-blocking Full Suite — so the required
**Typecheck** check was failing on *every* PR against main, including
this one. Inherited from `88b0db0f4` (FN-8309). **To restore when the
feature lands its dependency properly:** `git checkout 88b0db0f4 --
packages/dashboard/app/utils/capture-screenshot.ts` and add
`html2canvas` to `packages/dashboard/package.json` in the same change.
- **pg-gate rotating contention** — historically a different file set
each run with zero assertion failures. It passed 126/126 on the final
run here.
Two entries that were on the provisional ledger turned out **not** to be
pre-existing and are fixed in this PR: the
`workflow-graph-optional-step-fix` replan-cap pair were stale assertions
against U3's own contract change (cap-exhausted now *parks*
awaiting-approval and reports handled, rather than silently leaving the
task in place), and `executor-column-agent-seams` /
`executor-fast-mode-workflows` are green.
## Deferred follow-ups
- **Graph does not suspend at the ready-for-release seam.** A parked
`onNodeEntry` returns `void` and the node executes anyway, so within one
walk the card can run In-progress work while still displayed in Todo.
The benchmark models the scheduler explicitly for this reason and says
so at the seam. Making the graph actually suspend is U4-scope follow-up.
- **`needs-replan` reader migration.** Post-U3 the durable write happens
at the graph's own `plan-replan` seam, so the workflow *is* the writer
and the 14 readers form one coherent graph-owned loop — it is the
graph's durable replan signal wearing a legacy name, not un-migrated
legacy. The adoption census guard requiring that literal in
`executor.ts` is correct and stays. Migrating those readers to a
purpose-built run-state signal is a post-cutover naming change with its
own risk budget.
- **U9b seam-node refinement.** The merge substates
(`merging`/`merging-pr`/`merging-fix`) are adopted as `resume-graph`
rather than mapped to an exact re-entry node; naming a precise node
would require resolving the task's IR, which the adoption module
deliberately cannot do.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Workflows now drive task lifecycle columns, transitions, capacity
limits, review gates, and completion behavior—including custom
workflows.
* Tasks created with review levels automatically receive the
corresponding workflow review steps.
* Legacy in-progress tasks are automatically recovered during upgrades.
* Dashboard status badges now show the active workflow step name.
* Added safeguards for workflow changes, review ownership, database
compatibility, and workflow validation.
* **Bug Fixes**
* Fixed custom-column rendering and task movement.
* Improved merge-boundary handling and completion for workflows without
merge steps.
* Prevented cards from stalling, moving backward, or exceeding pooled
WIP capacity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/cutover-delete-fn-review-step.md
Normal file
7
.changeset/cutover-delete-fn-review-step.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Review gates now run only as workflow nodes — the in-session step reviewer is gone.
|
||||||
|
category: internal
|
||||||
|
dev: U10/R9 of the IR-driven lifecycle cutover deletes the `fn_review_step` executor tool, its RETHINK git-reset/session-rewind path, the per-step conversation checkpoint map, the deferred reviewer provider-error re-raise channel, and the review-level prompt scaffolding that told the model to call it. Plan/code/browser review are owned exclusively by workflow graph nodes.
|
||||||
7
.changeset/dashboard-custom-columns.md
Normal file
7
.changeset/dashboard-custom-columns.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Boards built on custom workflows now show and move cards in their own columns.
|
||||||
|
category: fix
|
||||||
|
dev: Operator surfaces closed the column set in four places. The dashboard ran every ingested task through `normalizeColumn`, which keeps only the six legacy ids and rewrote everything else to `triage`, so a card in a user-authored column rendered in Triage (new `normalizeColumnId` sanitizes structurally instead). `POST /tasks/:id/move` validated against the `COLUMNS` enum and answered 400 for any workflow-defined column; it now validates against the task's resolved IR and keys worktree allocation on the `wip` trait. Retry / reset / re-engage / unassign / spec-revise moved cards with hardcoded `"todo"`/`"in-progress"`/`"triage"` targets and gated spec revision on `VALID_TRANSITIONS`, all now derived from the task's workflow by trait. GitHub issue open/closed mapping keys on the `complete`/`archived` traits via an injected classifier whose default reproduces the legacy literal mapping. Status badges prefer the running workflow step's IR-declared name over raw engine status tokens.
|
||||||
7
.changeset/fix-builtin-workflow-lifecycle.md
Normal file
7
.changeset/fix-builtin-workflow-lifecycle.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix built-in workflows sending cards backward to Todo and stalling the PR workflow.
|
||||||
|
category: fix
|
||||||
|
dev: Unseamed nodes in linear built-ins (`security` in Review-heavy, `design-review` in Design, `review-handoff`/`document` in Compound Engineering) defaulted to the capacity-hold column, so the graph moved live cards back into Todo mid-run; they now inherit the preceding node's column. Separately, the `hold` node kind had no default handler, so every hold node threw "No handler registered" — Pull Request workflow cards died at `await-review`; holds now park in place like `manual-merge-hold`.
|
||||||
7
.changeset/fix-no-merge-workflow-completion.md
Normal file
7
.changeset/fix-no-merge-workflow-completion.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Workflows without a merge step now finish in their completion column instead of stalling one column short.
|
||||||
|
category: fix
|
||||||
|
dev: `end` is a graph terminal and never a column destination (KTD-1), so a card only entered the `complete`-trait column when a real node lived there — true for merge-bearing workflows via `post-merge-verification`, false for any no-merge workflow (e.g. Lead Generation stranded in `outreach`, never `converted`, which also blocked its dependents). Adds `advanceNoMergeWorkflowToCompleteColumn` on the executor's completed-disposition branch, keyed on the absence of a merge-orchestration column so merge-bearing workflows are untouched and `done` still requires a confirmed merge.
|
||||||
7
.changeset/fix-no-selection-default-workflow-drift.md
Normal file
7
.changeset/fix-no-selection-default-workflow-drift.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix tasks with no saved workflow selection being unable to move between columns.
|
||||||
|
category: fix
|
||||||
|
dev: Two resolvers disagreed on the no-selection default IR (catalog `builtin:coding` vs the legacy `BUILTIN_CODING_WORKFLOW_IR` constant), so the move-policy preflight signature never matched and the move threw "workflow move policy preflight is stale". Both sides plus `resolveTaskWorkflowIrSync` now share `resolveDefaultWorkflowIr()`.
|
||||||
7
.changeset/ir-driven-lifecycle-cutover.md
Normal file
7
.changeset/ir-driven-lifecycle-cutover.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Your workflow now drives the board — cards move through the columns you defined, not a fixed six.
|
||||||
|
category: feature
|
||||||
|
dev: The IR-driven lifecycle cutover makes the workflow IR the single authority over task lifecycle. Node column assignments move cards at runtime through the store's trait-hook `moveTask` path (attributed `workflowMoveSource: "workflow-graph"`, emitting `task:column-transition`), replacing the executor's hardcoded `moveTask(id, "in-review")` merge boundary and its handoff-invariant allowlist. Scheduler, hold/release, self-healing, merger and finalization re-key on column traits (`intake`/`hold`/`wip`/`merge-blocker`/`human-review`/`merge`/`complete`/`archived`/`timing`/`abort-on-exit`/`reset-on-entry`/`stall-detection`) instead of literal column ids, with rebound targets resolved per KTD-10. The graph exclusively owns Plan Review — triage's out-of-graph gate is deleted and `pending` step results are CAS-claimed leases (KTD-4), so duplicate reviewers can no longer race. `reviewLevel` becomes a creation-time preset writing `enabledWorkflowSteps` with zero runtime reads. Graph ownership is unconditional: the legacy execute fallback is deleted, `graphCompletion` is a required parameter, and a store that cannot resolve a workflow fails closed rather than silently running nothing. `builtin:coding` keeps its column ids and observable behavior byte-compatible, pinned by a characterization oracle plus a 6-column benchmark acceptance suite that drives a user-authored workflow Ideas -> Todo -> In-progress -> In-review -> Merging -> Done.
|
||||||
7
.changeset/legacy-adoption-and-ir-pin.md
Normal file
7
.changeset/legacy-adoption-and-ir-pin.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Tasks left mid-flight by an older Fusion are now adopted on upgrade instead of sitting stuck.
|
||||||
|
category: feature
|
||||||
|
dev: Migration 0026 adds `workflow_ir_pin`/`workflow_ir_pin_node_id`/`workflow_ir_pin_column_id` (KTD-3 durable per-node-entry IR pin) and `legacy_adopted_at` (KTD-8 one-time adoption stamp); SCHEMA_BASELINE_VERSION 0025 -> 0026. `planLegacyAdoption` is the shared decision run by both new consumers — the `adopt-legacy-task-rows` startup step in self-healing (ordered first, emits `task:reconcile-legacy-adoption` / `-unmappable`) and `adoptLegacyTaskRowsOnOpen` in the backend-mode store open path. Adoption stamps only rows it mutates, never disturbs user pauses or preserve gates, and parks unknown statuses `paused` with the status left visible. `assertBinaryNotOlderThanDatabase` refuses to open a database migrated by a newer binary (numeric comparison, unparseable markers ignored).
|
||||||
7
.changeset/six-column-merge-boundary.md
Normal file
7
.changeset/six-column-merge-boundary.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: A custom Merging column now receives the card at merge instead of being sent to In-review.
|
||||||
|
category: fix
|
||||||
|
dev: The workflow graph collapses the merge region into one seam recorded as node `merge`, but that synthetic node hardcoded `column: "in-review"`, so a user-authored workflow placing its merge nodes in a different column (e.g. `Merging`) had the card moved to `in-review` — a column such a workflow need not even declare. The column now derives from the merge-region node actually being entered, falling back to `in-review` so `builtin:coding` stays byte-identical. Caught by the new 6-column benchmark acceptance test.
|
||||||
@@ -22,7 +22,7 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||||
import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
||||||
import { isTaskAgentActive } from "../utils/taskActivity";
|
import { isTaskAgentActive } from "../utils/taskActivity";
|
||||||
import { getTaskStatusBadgeLabel, shouldSuppressPlanningStatusBadge } from "../utils/taskStatusBadgeLabel";
|
import { getTaskStatusBadgeLabel, shouldSuppressPlanningStatusBadge } from "../utils/taskStatusBadgeLabel";
|
||||||
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
|
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
|
||||||
@@ -69,9 +69,9 @@ type SortField = "title" | "status" | "column" | "retries";
|
|||||||
FNXC:MergeQueue 2026-07-15-10:45:
|
FNXC:MergeQueue 2026-07-15-10:45:
|
||||||
List status column used to print raw engine statuses (landing/reviewing). Share the board badge mapper so list and card never diverge.
|
List status column used to print raw engine statuses (landing/reviewing). Share the board badge mapper so list and card never diverge.
|
||||||
*/
|
*/
|
||||||
function getTaskStatusLabel(status: string, t: TFunction<"app">): string {
|
function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string {
|
||||||
if (status === "awaiting-approval") return t("tasks.awaitingApproval", "Awaiting Approval");
|
if (status === "awaiting-approval") return t("tasks.awaitingApproval", "Awaiting Approval");
|
||||||
return getTaskStatusBadgeLabel(status, t);
|
return getTaskStatusBadgeLabel(status, t, workflowStepLabel);
|
||||||
}
|
}
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
@@ -2743,7 +2743,7 @@ export function ListView({
|
|||||||
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
||||||
: isTransientPlannerActive
|
: isTransientPlannerActive
|
||||||
? t("tasks.statusPlanning", "Planning")
|
? t("tasks.statusPlanning", "Planning")
|
||||||
: getTaskStatusLabel(visualStatus ?? "", t)}
|
: getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{planReviewRunning && isAgentActive && (
|
{planReviewRunning && isAgentActive && (
|
||||||
@@ -2976,7 +2976,7 @@ export function ListView({
|
|||||||
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
||||||
: isTransientPlannerActive
|
: isTransientPlannerActive
|
||||||
? t("tasks.statusPlanning", "Planning")
|
? t("tasks.statusPlanning", "Planning")
|
||||||
: getTaskStatusLabel(visualStatus ?? "", t)}
|
: getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="list-status-badge">-</span>
|
<span className="list-status-badge">-</span>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { getStalledReviewSignal } from "../utils/taskStalledReview";
|
|||||||
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
||||||
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
|
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
|
||||||
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
|
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
|
||||||
import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
||||||
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
|
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
|
||||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||||
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
||||||
@@ -300,8 +300,8 @@ const TIME_INDICATOR_COLUMNS = new Set<ColumnId>([
|
|||||||
]);
|
]);
|
||||||
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
|
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
|
||||||
|
|
||||||
function getTaskStatusLabel(status: string, t: TFunction<"app">): string {
|
function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string {
|
||||||
return getTaskStatusBadgeLabel(status, t);
|
return getTaskStatusBadgeLabel(status, t, workflowStepLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDoneCompletionMs(task: Task): number | null {
|
function getDoneCompletionMs(task: Task): number | null {
|
||||||
@@ -3082,7 +3082,7 @@ function TaskCardComponent({
|
|||||||
? t("tasks.statusPlanning", "Planning")
|
? t("tasks.statusPlanning", "Planning")
|
||||||
: visualStatus === "merging-fix"
|
: visualStatus === "merging-fix"
|
||||||
? t("tasks.statusMergingFix", "Merging fixes…")
|
? t("tasks.statusMergingFix", "Merging fixes…")
|
||||||
: getTaskStatusLabel(visualStatus!, t)}
|
: getTaskStatusLabel(visualStatus!, t, getRunningWorkflowStepLabel(task))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{planReviewRunning && isAgentActive && (
|
{planReviewRunning && isAgentActive && (
|
||||||
|
|||||||
@@ -1604,6 +1604,14 @@ export function TaskForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onReviewLevelChange && (
|
{onReviewLevelChange && (
|
||||||
|
/*
|
||||||
|
FNXC:ReviewLevelPreset 2026-07-19-10:50 (U8 / R6):
|
||||||
|
Review is now a CREATION-TIME preset over the workflow's optional review
|
||||||
|
steps (level -> enabledWorkflowSteps at create), not a runtime signal
|
||||||
|
triage decides. The default (unset) leaves the workflow's own default-on
|
||||||
|
steps in place; an explicit optional-step selection always wins over the
|
||||||
|
level preset (server-side applyReviewLevelPreset).
|
||||||
|
*/
|
||||||
<div className="model-select-row">
|
<div className="model-select-row">
|
||||||
<label htmlFor="review-level" className="model-select-label">{t("taskForm.reviewLabel", "Review")}</label>
|
<label htmlFor="review-level" className="model-select-label">{t("taskForm.reviewLabel", "Review")}</label>
|
||||||
<select
|
<select
|
||||||
@@ -1612,11 +1620,11 @@ export function TaskForm({
|
|||||||
onChange={(e) => onReviewLevelChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
|
onChange={(e) => onReviewLevelChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<option value="">{t("taskForm.reviewDefault", "Default (Auto — triage decides)")}</option>
|
<option value="">{t("taskForm.reviewDefault", "Default (workflow review steps)")}</option>
|
||||||
<option value="0">{t("taskForm.reviewLevel0", "0 — None")}</option>
|
<option value="0">{t("taskForm.reviewLevel0", "0 — None")}</option>
|
||||||
<option value="1">{t("taskForm.reviewLevel1", "1 — Plan Only")}</option>
|
<option value="1">{t("taskForm.reviewLevel1", "1 — Code Review")}</option>
|
||||||
<option value="2">{t("taskForm.reviewLevel2", "2 — Plan and Code")}</option>
|
<option value="2">{t("taskForm.reviewLevel2", "2 — Plan + Code")}</option>
|
||||||
<option value="3">{t("taskForm.reviewLevel3", "3 — Full")}</option>
|
<option value="3">{t("taskForm.reviewLevel3", "3 — Plan + Browser + Code")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2390,7 +2390,10 @@ describe("TaskCard", () => {
|
|||||||
expect(Boolean(badge)).toBe(shouldRender);
|
expect(Boolean(badge)).toBe(shouldRender);
|
||||||
if (shouldRender) {
|
if (shouldRender) {
|
||||||
expect(badge).toHaveTextContent("Reviewing");
|
expect(badge).toHaveTextContent("Reviewing");
|
||||||
expect(screen.getByText("planning")).toBeDefined();
|
// FNXC:StatusBadge 2026-07-19-04:30: U12 — the status badge prefers the running
|
||||||
|
// workflow step's IR-declared name ("Plan Review") over the raw engine token
|
||||||
|
// ("planning"); this expectation tracks that intentional cutover behavior.
|
||||||
|
expect(screen.getByText("Plan Review")).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
|
import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, AgentLogEntry } from "@fusion/core";
|
||||||
import { normalizeColumn } from "@fusion/core";
|
import { normalizeColumnId } from "@fusion/core";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||||
@@ -10,10 +10,21 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
|||||||
const loggedTaskCacheHitProjects = new Set<string>();
|
const loggedTaskCacheHitProjects = new Set<string>();
|
||||||
const TASK_VIEW_REENTRY_FRESHNESS_MS = SWR_TASKS_MAX_AGE_MS;
|
const TASK_VIEW_REENTRY_FRESHNESS_MS = SWR_TASKS_MAX_AGE_MS;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2b:05 (U12 / R2 / R11):
|
||||||
|
Every task the dashboard ingests — initial list, SWR revalidation, and each SSE event — passes
|
||||||
|
through here, so this one line decided whether custom columns exist in the UI at all. It used
|
||||||
|
`normalizeColumn`, which keeps only the six legacy ids and rewrites everything else to `triage`:
|
||||||
|
a card sitting in a user-authored `Merging` column rendered in Triage, and dragging it appeared to
|
||||||
|
do nothing. The move handler below already worked around this for its own `to` id ("normalizeColumn
|
||||||
|
alone would drop custom ids"), which fixed the symptom for one event and left the ingest path lossy.
|
||||||
|
`normalizeColumnId` sanitizes structurally (non-string/empty -> fallback) and passes real ids
|
||||||
|
through; membership belongs to the task's resolved workflow, not to a client-side enum.
|
||||||
|
*/
|
||||||
function normalizeTask(task: Task): Task {
|
function normalizeTask(task: Task): Task {
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
column: normalizeColumn((task as Task & { column?: unknown }).column),
|
column: normalizeColumnId((task as Task & { column?: unknown }).column),
|
||||||
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
|
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
|
||||||
steps: Array.isArray(task.steps) ? task.steps : [],
|
steps: Array.isArray(task.steps) ? task.steps : [],
|
||||||
log: Array.isArray((task as Task & { log?: unknown }).log)
|
log: Array.isArray((task as Task & { log?: unknown }).log)
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ describe("getTaskStatusBadgeLabel", () => {
|
|||||||
expect(getTaskStatusBadgeLabel("merging-fix", t)).toBe("Merging fixes…");
|
expect(getTaskStatusBadgeLabel("merging-fix", t)).toBe("Merging fixes…");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps merging-fix over a still-running workflow-step label", () => {
|
||||||
|
// A pre-merge step's running state can survive into a merge-fix retry; the badge must not regress to the step name.
|
||||||
|
expect(getTaskStatusBadgeLabel("merging-fix", t, "Plan Review")).toBe("Merging fixes…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every active-merge status over a still-running workflow-step label", () => {
|
||||||
|
// The same stale startedAt-without-completedAt step state can survive into the whole merge pipeline.
|
||||||
|
for (const status of ["merging", "merging-pr", "reviewing", "landing"]) {
|
||||||
|
expect(getTaskStatusBadgeLabel(status, t, "Code Review")).toBe("Merging…");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a running workflow-step label override other statuses", () => {
|
||||||
|
expect(getTaskStatusBadgeLabel("planning", t, "Plan Review")).toBe("Plan Review");
|
||||||
|
expect(getTaskStatusBadgeLabel("needs-replan", t, "Plan Review")).toBe("Plan Review");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps needs-replan to the operator-facing Replan label", () => {
|
it("maps needs-replan to the operator-facing Replan label", () => {
|
||||||
expect(getTaskStatusBadgeLabel("needs-replan", t)).toBe("Replan");
|
expect(getTaskStatusBadgeLabel("needs-replan", t)).toBe("Replan");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import html2canvas from "html2canvas";
|
|
||||||
|
|
||||||
const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
export interface CapturedScreenshot {
|
|
||||||
blob: Blob;
|
|
||||||
previewUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FNXC:ReportPipeline 2026-07-16-10:00:
|
|
||||||
* Screenshot capture deliberately snapshots only Fusion's app root, never the
|
|
||||||
* display. This avoids collecting other tabs or operating-system windows.
|
|
||||||
*/
|
|
||||||
export async function captureAppScreenshot(): Promise<CapturedScreenshot> {
|
|
||||||
const root = document.querySelector("#root");
|
|
||||||
if (!root) throw new Error("The dashboard view is not available for capture.");
|
|
||||||
const canvas = await html2canvas(root as HTMLElement, { scale: 0.75, useCORS: true, logging: false });
|
|
||||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.8));
|
|
||||||
if (!blob || blob.size > MAX_SCREENSHOT_BYTES) throw new Error("The screenshot is too large. Try a smaller dashboard view.");
|
|
||||||
return { blob, previewUrl: URL.createObjectURL(blob) };
|
|
||||||
}
|
|
||||||
@@ -140,6 +140,27 @@ export function getUnifiedTaskProgress(
|
|||||||
return { total, completed, items };
|
return { total, completed, items };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:TaskStatusBadge 2026-07-19-2b:55 (U12 / R2 / R11):
|
||||||
|
The workflow-step-derived badge label. Operator surfaces used to render raw engine status tokens
|
||||||
|
("planning", "needs-replan"), which name ENGINE bookkeeping rather than the stage the card is
|
||||||
|
actually in — and which no user-authored workflow has any reason to recognize. When a workflow step
|
||||||
|
is running, its own IR-declared name ("Plan Review", "Code Review") is both truer and workflow-owned,
|
||||||
|
so it takes precedence over the status vocabulary.
|
||||||
|
|
||||||
|
Returns undefined when nothing is running, leaving the status mapping as the fallback. The engine
|
||||||
|
statuses themselves are unchanged — `needs-replan` remains the graph's durable replan signal; this
|
||||||
|
only decides what the operator READS.
|
||||||
|
*/
|
||||||
|
export function getRunningWorkflowStepLabel(
|
||||||
|
task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
|
||||||
|
): string | undefined {
|
||||||
|
const running = getUnifiedTaskProgress(task).items.find(
|
||||||
|
(item) => item.source === "workflow" && item.status === "running",
|
||||||
|
);
|
||||||
|
return running?.name;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:00:
|
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:00:
|
||||||
FN-7831 requires task cards and list rows to show a distinct "Reviewing" badge only while the optional `plan-review` workflow step is actively running. Reuse the unified progress item status so every board surface follows the same startedAt-without-completedAt semantics as the progress list.
|
FN-7831 requires task cards and list rows to show a distinct "Reviewing" badge only while the optional `plan-review` workflow step is actively running. Reuse the unified progress item status so every board surface follows the same startedAt-without-completedAt semantics as the progress list.
|
||||||
|
|||||||
@@ -22,14 +22,30 @@ export function shouldSuppressPlanningStatusBadge({
|
|||||||
export function getTaskStatusBadgeLabel(
|
export function getTaskStatusBadgeLabel(
|
||||||
status: string | null | undefined,
|
status: string | null | undefined,
|
||||||
t: TFunction<"app">,
|
t: TFunction<"app">,
|
||||||
|
/*
|
||||||
|
FNXC:TaskStatusBadge 2026-07-19-02:55 (U12 / R2 / R11):
|
||||||
|
Workflow-step state wins over the raw status vocabulary. A card whose Plan Review is running
|
||||||
|
reads "Plan Review" — the step's own IR-declared name — instead of the engine token "planning"
|
||||||
|
or "needs-replan". Pass `getRunningWorkflowStepLabel(task)` here; omit it and the legacy status
|
||||||
|
mapping below is unchanged, so every existing caller keeps its behavior.
|
||||||
|
*/
|
||||||
|
workflowStepLabel?: string,
|
||||||
): string {
|
): string {
|
||||||
if (!status) return "";
|
/*
|
||||||
if (status === "merging-fix") {
|
FNXC:TaskStatusBadge 2026-07-19-09:40:
|
||||||
return t("tasks.statusMergingFix", "Merging fixes…");
|
Every active-merge status ("merging", "merging-pr", "merging-fix", "reviewing", "landing") must
|
||||||
}
|
win over a still-running workflow-step label (a pre-merge step's startedAt-without-completedAt
|
||||||
|
state can survive into the merge pipeline). Checking the status before the workflow-step override
|
||||||
|
enforces this for every caller (TaskCard, ListView grouped rows, ListView table rows) instead of
|
||||||
|
relying on per-call-site pre-checks. "merging-fix" keeps its distinct "Merging fixes…" label.
|
||||||
|
*/
|
||||||
if (isActiveMergeStatus(status)) {
|
if (isActiveMergeStatus(status)) {
|
||||||
return t("tasks.statusMerging", "Merging…");
|
return status === "merging-fix"
|
||||||
|
? t("tasks.statusMergingFix", "Merging fixes…")
|
||||||
|
: t("tasks.statusMerging", "Merging…");
|
||||||
}
|
}
|
||||||
|
if (workflowStepLabel) return workflowStepLabel;
|
||||||
|
if (!status) return "";
|
||||||
/*
|
/*
|
||||||
FNXC:TaskStatusBadge 2026-07-28-00:00:
|
FNXC:TaskStatusBadge 2026-07-28-00:00:
|
||||||
FN-8195 requires the raw engine status "needs-replan" to appear as "Replan" on board cards
|
FN-8195 requires the raw engine status "needs-replan" to appear as "Replan" on board cards
|
||||||
|
|||||||
238
packages/dashboard/src/__tests__/routes-trait-rekey.test.ts
Normal file
238
packages/dashboard/src/__tests__/routes-trait-rekey.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseWorkflowIr, resolveReboundTarget, columnsWithFlag, normalizeColumnId } from "@fusion/core";
|
||||||
|
import type { WorkflowIr } from "@fusion/core";
|
||||||
|
import "@fusion/core"; // registers the built-in traits the trait resolvers read
|
||||||
|
|
||||||
|
import { decideIssueAction, legacyColumnLifecycleClass } from "../github-tracking-state.js";
|
||||||
|
import { sixColumnWorkflowIr } from "../../../engine/src/__tests__/fixtures/six-column-workflow-ir.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2c:10 (U12 / R2 / R11):
|
||||||
|
The operator-surface half of the cutover: every dashboard/API decision that used to key on the
|
||||||
|
closed six-id column enum must key on the TASK'S WORKFLOW instead.
|
||||||
|
|
||||||
|
These are deliberately pure-function tests over the seams U12 re-keyed, not route integration
|
||||||
|
tests. The value is in the DECISION (which column, which GitHub state, which label), and pinning it
|
||||||
|
directly is both faster and harder to fool than asserting through an Express stack whose store stub
|
||||||
|
would have to fake IR resolution anyway. Route wiring is exercised by the existing
|
||||||
|
`routes-task-*.test.ts` suites.
|
||||||
|
|
||||||
|
The fixture is U11's benchmark IR — the same object its acceptance runner drives — so "the editor
|
||||||
|
can build it" and "the runner can run it" are statements about ONE artifact, which is what R11 asks
|
||||||
|
for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BENCHMARK_IR = sixColumnWorkflowIr();
|
||||||
|
|
||||||
|
describe("U12: column ids stay open across operator surfaces", () => {
|
||||||
|
/*
|
||||||
|
The defect this pins: the dashboard ran every ingested task through `normalizeColumn`, which
|
||||||
|
keeps only the six legacy ids and rewrites everything else to `triage`. A card in a custom
|
||||||
|
`merging` column rendered in Triage and dragging it appeared to do nothing.
|
||||||
|
*/
|
||||||
|
it("preserves a novel column id instead of coercing it to a legacy one", () => {
|
||||||
|
expect(normalizeColumnId("merging")).toBe("merging");
|
||||||
|
expect(normalizeColumnId("ideas")).toBe("ideas");
|
||||||
|
// Structurally unusable values still fall back — that is the only coercion left.
|
||||||
|
expect(normalizeColumnId("")).toBe("triage");
|
||||||
|
expect(normalizeColumnId(undefined)).toBe("triage");
|
||||||
|
expect(normalizeColumnId(null, "todo")).toBe("todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves lifecycle move targets from the workflow, not from literals", () => {
|
||||||
|
// Rebound (retry / reset / unassign) targets the hold column...
|
||||||
|
expect(resolveReboundTarget(BENCHMARK_IR)).toBe("todo");
|
||||||
|
// ...execution targets the wip column, and intake is its own thing.
|
||||||
|
expect(columnsWithFlag(BENCHMARK_IR, "countsTowardWip")[0]).toBe("in-progress");
|
||||||
|
expect(columnsWithFlag(BENCHMARK_IR, "intake")[0]).toBe("ideas");
|
||||||
|
|
||||||
|
/*
|
||||||
|
The point of deriving rather than hardcoding: a workflow with NO `todo` and NO `triage` still
|
||||||
|
resolves a real destination. Under the old literals the operator's Retry button either threw
|
||||||
|
or parked the card in a column the workflow never declared.
|
||||||
|
*/
|
||||||
|
const renamed = parseWorkflowIr({
|
||||||
|
version: "v2",
|
||||||
|
name: "renamed-lifecycle",
|
||||||
|
columns: [
|
||||||
|
{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] },
|
||||||
|
{ id: "queue", name: "Queue", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||||
|
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
|
||||||
|
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "inbox" },
|
||||||
|
{ id: "build", kind: "prompt", column: "building", config: { seam: "execute" } },
|
||||||
|
{ id: "end", kind: "end", column: "shipped" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "start", to: "build" }, { from: "build", to: "end", condition: "success" }],
|
||||||
|
} as never) as WorkflowIr;
|
||||||
|
|
||||||
|
expect(resolveReboundTarget(renamed)).toBe("queue");
|
||||||
|
expect(columnsWithFlag(renamed, "countsTowardWip")[0]).toBe("building");
|
||||||
|
expect(columnsWithFlag(renamed, "intake")[0]).toBe("inbox");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("U12: GitHub issue state keys on complete/archived traits", () => {
|
||||||
|
/** Classify by TRAIT, the way the re-keyed caller does, rather than by literal id. */
|
||||||
|
const classifyByTrait = (ir: WorkflowIr) => {
|
||||||
|
const complete = new Set(columnsWithFlag(ir, "complete"));
|
||||||
|
const archived = new Set(columnsWithFlag(ir, "archived"));
|
||||||
|
return (columnId: string) => ({ complete: complete.has(columnId), archived: archived.has(columnId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
it("closes the issue when a card reaches a workflow's own complete column", () => {
|
||||||
|
const classify = classifyByTrait(BENCHMARK_IR);
|
||||||
|
// The benchmark's complete column happens to be `done`, but the decision is trait-driven:
|
||||||
|
expect(decideIssueAction("merging", "done", classify)).toEqual({
|
||||||
|
action: "close",
|
||||||
|
stateReason: "completed",
|
||||||
|
});
|
||||||
|
// A move between non-terminal columns is not a GitHub event at all.
|
||||||
|
expect(decideIssueAction("in-review", "merging", classify)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on a RENAMED complete column, which the literal mapping missed entirely", () => {
|
||||||
|
const renamed = parseWorkflowIr({
|
||||||
|
version: "v2",
|
||||||
|
name: "renamed-terminal",
|
||||||
|
columns: [
|
||||||
|
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
|
||||||
|
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||||
|
{ id: "dropped", name: "Dropped", traits: [{ trait: "archived" }] },
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "building" },
|
||||||
|
{ id: "end", kind: "end", column: "shipped" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "start", to: "end" }],
|
||||||
|
} as never) as WorkflowIr;
|
||||||
|
const classify = classifyByTrait(renamed);
|
||||||
|
|
||||||
|
expect(decideIssueAction("building", "shipped", classify)).toEqual({
|
||||||
|
action: "close",
|
||||||
|
stateReason: "completed",
|
||||||
|
});
|
||||||
|
expect(decideIssueAction("building", "dropped", classify)).toEqual({
|
||||||
|
action: "close",
|
||||||
|
stateReason: "not_planned",
|
||||||
|
});
|
||||||
|
expect(decideIssueAction("shipped", "building", classify)).toEqual({
|
||||||
|
action: "reopen",
|
||||||
|
stateReason: "reopened",
|
||||||
|
});
|
||||||
|
expect(decideIssueAction("dropped", "shipped", classify)).toEqual({
|
||||||
|
action: "reopen",
|
||||||
|
stateReason: "reopened",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression this replaces: with the legacy literal classifier, none of it fires.
|
||||||
|
expect(decideIssueAction("building", "shipped", legacyColumnLifecycleClass)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the default workflow byte-identical under the default classifier", () => {
|
||||||
|
expect(decideIssueAction("in-review", "done")).toEqual({ action: "close", stateReason: "completed" });
|
||||||
|
expect(decideIssueAction("done", "archived")).toEqual({ action: "close", stateReason: "completed" });
|
||||||
|
expect(decideIssueAction("todo", "archived")).toEqual({ action: "close", stateReason: "not_planned" });
|
||||||
|
expect(decideIssueAction("archived", "done")).toEqual({ action: "reopen", stateReason: "reopened" });
|
||||||
|
expect(decideIssueAction("done", "todo")).toEqual({ action: "reopen", stateReason: "reopened" });
|
||||||
|
expect(decideIssueAction("todo", "in-progress")).toBeNull();
|
||||||
|
expect(decideIssueAction("archived", "archived")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2c:20 (U12 / R11 first half):
|
||||||
|
EDITOR BUILDABILITY. R11 asks that the 6-column benchmark be constructible in the workflow editor,
|
||||||
|
not merely hand-written as a test fixture. The editor's save path validates through
|
||||||
|
`parseWorkflowIr` (via `validateWorkflowIrDryRun`), so parsing the benchmark IR and getting back a
|
||||||
|
usable v2 graph IS the buildability claim — and because U11's runner drives this exact object, the
|
||||||
|
saved artifact is provably byte-usable by the runtime.
|
||||||
|
*/
|
||||||
|
describe("U12: the 6-column benchmark is editor-buildable (R11)", () => {
|
||||||
|
it("passes save validation and round-trips its columns, traits, nodes and caps", () => {
|
||||||
|
const parsed = parseWorkflowIr(BENCHMARK_IR as never) as WorkflowIr & {
|
||||||
|
columns: Array<{ id: string; traits: Array<{ trait: string }> }>;
|
||||||
|
nodes: Array<{ id: string; column?: string; config?: Record<string, unknown> }>;
|
||||||
|
edges: Array<{ from: string; to: string; condition?: string; kind?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.version).toBe("v2");
|
||||||
|
// Six columns, in the operator's documented order.
|
||||||
|
expect(parsed.columns.map((c) => c.id)).toEqual([
|
||||||
|
"ideas",
|
||||||
|
"todo",
|
||||||
|
"in-progress",
|
||||||
|
"in-review",
|
||||||
|
"merging",
|
||||||
|
"done",
|
||||||
|
]);
|
||||||
|
// Each column kept the traits the contract assigns it.
|
||||||
|
const traitsOf = (id: string) =>
|
||||||
|
parsed.columns.find((c) => c.id === id)!.traits.map((t) => t.trait).sort();
|
||||||
|
expect(traitsOf("ideas")).toEqual(["intake"]);
|
||||||
|
expect(traitsOf("todo")).toEqual(["hold", "reset-on-entry"]);
|
||||||
|
expect(traitsOf("in-progress")).toEqual(["abort-on-exit", "timing", "wip"]);
|
||||||
|
expect(traitsOf("in-review")).toEqual(["merge-blocker", "stall-detection"]);
|
||||||
|
// Merging carries the human gate — the operator contract's settled placement.
|
||||||
|
expect(traitsOf("merging")).toEqual(["human-review", "merge"]);
|
||||||
|
expect(traitsOf("done")).toEqual(["complete"]);
|
||||||
|
|
||||||
|
// Review nodes sit in the hold column (pre-release Plan Review) and In-review respectively.
|
||||||
|
const nodeColumn = (id: string) => parsed.nodes.find((n) => n.id === id)?.column;
|
||||||
|
expect(nodeColumn("plan-review")).toBe("todo");
|
||||||
|
expect(nodeColumn("code-review")).toBe("in-review");
|
||||||
|
// The remediation node sits in In-progress — that placement is what makes the REVISE
|
||||||
|
// round-trip a visible backward column move rather than an in-place retry.
|
||||||
|
expect(nodeColumn("code-review-remediation")).toBe("in-progress");
|
||||||
|
|
||||||
|
// The remediation edges survive as rework edges (the only legal cycles).
|
||||||
|
const reworkEdges = parsed.edges.filter((e) => e.kind === "rework");
|
||||||
|
expect(reworkEdges).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ from: "plan-replan", to: "plan-review" }),
|
||||||
|
expect.objectContaining({ from: "code-review-remediation", to: "code-review" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-node caps are workflow config and survive the round trip.
|
||||||
|
const configOf = (id: string) => parsed.nodes.find((n) => n.id === id)?.config ?? {};
|
||||||
|
expect(configOf("plan-review").maxReworkCycles).toBe(1);
|
||||||
|
expect(configOf("code-review").maxReworkCycles).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The negative case R11 asks for. "Node references a missing column" must be REJECTED at save, not
|
||||||
|
silently accepted and then discovered at runtime when the boundary tries to move a card into a
|
||||||
|
column that does not exist.
|
||||||
|
*/
|
||||||
|
it("rejects a node pointing at a column the workflow does not declare", () => {
|
||||||
|
const broken = {
|
||||||
|
...(BENCHMARK_IR as unknown as Record<string, unknown>),
|
||||||
|
nodes: (BENCHMARK_IR as unknown as { nodes: Array<Record<string, unknown>> }).nodes.map((n) =>
|
||||||
|
n.id === "merge-attempt" ? { ...n, column: "nonexistent-column" } : n,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => parseWorkflowIr(broken as never)).toThrow(
|
||||||
|
/references undefined column 'nonexistent-column'/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects the invalid graph when every column id is novel", () => {
|
||||||
|
const broken = {
|
||||||
|
version: "v2",
|
||||||
|
name: "novel-ids",
|
||||||
|
columns: [{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }],
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "inbox" },
|
||||||
|
{ id: "work", kind: "prompt", column: "not-declared" },
|
||||||
|
{ id: "end", kind: "end", column: "inbox" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "start", to: "work" }, { from: "work", to: "end", condition: "success" }],
|
||||||
|
};
|
||||||
|
expect(() => parseWorkflowIr(broken as never)).toThrow(/references undefined column/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,36 +19,62 @@ interface TaskMovedEvent {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
// #1403: the store's `task:moved` event now carries `ColumnId` (custom column
|
// #1403: the store's `task:moved` event carries `ColumnId` (custom column ids
|
||||||
// ids admitted). These handlers only literal-compare against legacy ids, so a
|
// admitted). U12 re-keys the decision on the complete/archived traits, so a
|
||||||
// string-widened field is safe.
|
// workflow-defined terminal column maps to GitHub state like `done` does.
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2b:50 (U12 / R2):
|
||||||
|
GitHub open/closed state keys on the `complete` and `archived` TRAITS, not the literal ids `done`
|
||||||
|
and `archived`. A user-authored workflow whose terminal column is called something else never
|
||||||
|
closed its linked GitHub issue, and a custom archive column never mapped to `not_planned`.
|
||||||
|
|
||||||
|
`classify` is injected rather than resolved here so this stays a pure decision function (the
|
||||||
|
caller owns IR resolution). Its default reproduces the legacy literal mapping exactly, so every
|
||||||
|
existing caller and the default workflow are byte-identical.
|
||||||
|
*/
|
||||||
|
export interface ColumnLifecycleClass {
|
||||||
|
complete: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const legacyColumnLifecycleClass = (columnId: string): ColumnLifecycleClass => ({
|
||||||
|
complete: columnId === "done",
|
||||||
|
archived: columnId === "archived",
|
||||||
|
});
|
||||||
|
|
||||||
export function decideIssueAction(
|
export function decideIssueAction(
|
||||||
from: string,
|
from: string,
|
||||||
to: string,
|
to: string,
|
||||||
|
classify: (columnId: string) => ColumnLifecycleClass = legacyColumnLifecycleClass,
|
||||||
): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null {
|
): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null {
|
||||||
if (from === "archived" && to === "done") {
|
const fromClass = classify(from);
|
||||||
|
const toClass = classify(to);
|
||||||
|
|
||||||
|
// Un-archiving back into the completed column re-opens the issue.
|
||||||
|
if (fromClass.archived && toClass.complete) {
|
||||||
return { action: "reopen", stateReason: "reopened" };
|
return { action: "reopen", stateReason: "reopened" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to === "done" && from !== "done") {
|
if (toClass.complete && !fromClass.complete) {
|
||||||
return { action: "close", stateReason: "completed" };
|
return { action: "close", stateReason: "completed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to === "archived") {
|
if (toClass.archived) {
|
||||||
if (from === "done") {
|
if (fromClass.complete) {
|
||||||
return { action: "close", stateReason: "completed" };
|
return { action: "close", stateReason: "completed" };
|
||||||
}
|
}
|
||||||
if (from !== "archived") {
|
if (!fromClass.archived) {
|
||||||
return { action: "close", stateReason: "not_planned" };
|
return { action: "close", stateReason: "not_planned" };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from === "done" && to !== "done") {
|
// Leaving the completed column re-opens the issue.
|
||||||
|
if (fromClass.complete && !toClass.complete) {
|
||||||
return { action: "reopen", stateReason: "reopened" };
|
return { action: "reopen", stateReason: "reopened" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ import {
|
|||||||
isWorkflowColumnsEnabled,
|
isWorkflowColumnsEnabled,
|
||||||
resolveWorkflowIrForTask,
|
resolveWorkflowIrForTask,
|
||||||
workflowHasColumn,
|
workflowHasColumn,
|
||||||
|
columnHasFlag,
|
||||||
|
columnsWithFlag,
|
||||||
|
resolveReboundTarget,
|
||||||
resolveColumnFlags,
|
resolveColumnFlags,
|
||||||
TransitionRejectionError,
|
TransitionRejectionError,
|
||||||
getPlannerInterventionTimeline,
|
getPlannerInterventionTimeline,
|
||||||
@@ -109,6 +112,53 @@ async function clearRebuiltSpecWorkflowPins(store: TaskStore, taskId: string): P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2b:30 (U12 / R2 / R11):
|
||||||
|
IR-derived move targets for the operator lifecycle routes.
|
||||||
|
|
||||||
|
Retry / reset / re-engage all moved the card with a hardcoded `"todo"` or `"in-progress"`. On a
|
||||||
|
user-authored workflow those ids may not exist at all, so the operator's Retry button either threw
|
||||||
|
or silently parked the card in a column the workflow never declared. These resolve the destination
|
||||||
|
from the TASK'S OWN workflow by TRAIT — the rebound target is the `hold` column (falling back to
|
||||||
|
`intake`), the execution target is the column carrying `wip`.
|
||||||
|
|
||||||
|
Both fall back to the legacy literal when the IR cannot be resolved or declares no columns (v1),
|
||||||
|
so `builtin:coding` — whose hold column IS `todo` and whose wip column IS `in-progress` — keeps
|
||||||
|
byte-identical behavior (KTD-7).
|
||||||
|
*/
|
||||||
|
async function resolveReboundColumnForTask(store: TaskStore, taskId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const ir = await resolveWorkflowIrForTask(store, taskId);
|
||||||
|
return resolveReboundTarget(ir) ?? "todo";
|
||||||
|
} catch {
|
||||||
|
return "todo";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2b:35 (U12 / R2):
|
||||||
|
Spec revision rehomes to the workflow's INTAKE column (where specification happens), which is a
|
||||||
|
different preference from the rebound target above — rebound prefers `hold`, respecify prefers
|
||||||
|
`intake`. `builtin:coding`'s intake column IS `triage`, so the default path is unchanged.
|
||||||
|
*/
|
||||||
|
async function resolveIntakeColumnForTask(store: TaskStore, taskId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const ir = await resolveWorkflowIrForTask(store, taskId);
|
||||||
|
return columnsWithFlag(ir, "intake")[0] ?? "triage";
|
||||||
|
} catch {
|
||||||
|
return "triage";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWipColumnForTask(store: TaskStore, taskId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const ir = await resolveWorkflowIrForTask(store, taskId);
|
||||||
|
return columnsWithFlag(ir, "countsTowardWip")[0] ?? "in-progress";
|
||||||
|
} catch {
|
||||||
|
return "in-progress";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isArtifactType(value: string): value is ArtifactType {
|
function isArtifactType(value: string): value is ArtifactType {
|
||||||
return ARTIFACT_TYPES.has(value as ArtifactType);
|
return ARTIFACT_TYPES.has(value as ArtifactType);
|
||||||
}
|
}
|
||||||
@@ -728,7 +778,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
|
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
const reengagedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true });
|
const reengageColumn = await resolveWipColumnForTask(scopedStore, task.id);
|
||||||
|
const reengagedTask = await scopedStore.moveTask(task.id, reengageColumn, { preserveProgress: true });
|
||||||
await triggerCommentWakeForAssignedAgent(scopedStore, reengagedTask, wake);
|
await triggerCommentWakeForAssignedAgent(scopedStore, reengagedTask, wake);
|
||||||
return { task: reengagedTask, reengaged: true };
|
return { task: reengagedTask, reengaged: true };
|
||||||
}
|
}
|
||||||
@@ -1617,8 +1668,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const { column, preserveProgress } = req.body;
|
const { column, preserveProgress } = req.body;
|
||||||
if (!column || !COLUMNS.includes(column as Column)) {
|
/*
|
||||||
throw badRequest(`Invalid column. Must be one of: ${COLUMNS.join(", ")}`);
|
FNXC:WorkflowColumns 2026-07-19-2b:15 (U12 / R2 / R11):
|
||||||
|
Validate against the TASK'S WORKFLOW, not the legacy six-id enum. This endpoint rejected
|
||||||
|
every workflow-defined column outright — a board built on a custom workflow could not move a
|
||||||
|
card into its own `Merging` column, the API answered 400 "Must be one of: triage, todo, ...".
|
||||||
|
That is the closed-enum blocker the cutover exists to remove.
|
||||||
|
Resolution failure or a v1 (columnless) IR falls back to the legacy set, so the default
|
||||||
|
workflow and older definitions behave exactly as before.
|
||||||
|
*/
|
||||||
|
if (typeof column !== "string" || !column) {
|
||||||
|
throw badRequest("Invalid column. Expected a non-empty column id.");
|
||||||
|
}
|
||||||
|
const moveTargetIr = await resolveWorkflowIrForTask(scopedStore, req.params.id).catch(() => undefined);
|
||||||
|
const declaresColumns = Array.isArray((moveTargetIr as { columns?: unknown[] } | undefined)?.columns);
|
||||||
|
const columnIsValid = moveTargetIr && declaresColumns
|
||||||
|
? workflowHasColumn(moveTargetIr, column)
|
||||||
|
: COLUMNS.includes(column as Column);
|
||||||
|
if (!columnIsValid) {
|
||||||
|
const allowed = moveTargetIr && declaresColumns
|
||||||
|
? ((moveTargetIr as unknown as { columns: Array<{ id: string }> }).columns.map((c) => c.id))
|
||||||
|
: [...COLUMNS];
|
||||||
|
throw badRequest(`Invalid column. Must be one of: ${allowed.join(", ")}`);
|
||||||
}
|
}
|
||||||
if (preserveProgress != null && typeof preserveProgress !== "boolean") {
|
if (preserveProgress != null && typeof preserveProgress !== "boolean") {
|
||||||
throw badRequest("preserveProgress must be a boolean");
|
throw badRequest("preserveProgress must be a boolean");
|
||||||
@@ -1662,7 +1733,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
// already exists, so any prior committed progress survives even
|
// already exists, so any prior committed progress survives even
|
||||||
// though the on-disk worktree directory is freshly allocated.
|
// though the on-disk worktree directory is freshly allocated.
|
||||||
let allocateWorktree: ((reservedNames: Set<string>) => string | null) | undefined;
|
let allocateWorktree: ((reservedNames: Set<string>) => string | null) | undefined;
|
||||||
if ((column as Column) === "in-progress") {
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-2b:20 (U12 / R2):
|
||||||
|
Allocate a worktree when promoting into a WIP column, keyed on the trait rather than the
|
||||||
|
literal `in-progress` id. A custom workflow's execution column carries `wip` under its own
|
||||||
|
name, and without this it landed in-progress with a null worktree.
|
||||||
|
*/
|
||||||
|
const targetIsWip = moveTargetIr && declaresColumns
|
||||||
|
? columnHasFlag(moveTargetIr, column, "countsTowardWip")
|
||||||
|
: column === "in-progress";
|
||||||
|
if (targetIsWip) {
|
||||||
const existing = await scopedStore.getTask(req.params.id);
|
const existing = await scopedStore.getTask(req.params.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
@@ -2455,6 +2535,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
|
const retryLogSuffix = clearedDeadlockAutoPause ? ", cleared deadlock auto-pause" : "";
|
||||||
|
|
||||||
if (isMissingWorktreeSessionRetry) {
|
if (isMissingWorktreeSessionRetry) {
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-11:05 (U12 review):
|
||||||
|
Resolve the rebound destination BEFORE logging so the audit entry reports the real
|
||||||
|
trait-derived column instead of a hardcoded "todo", which misleads on custom boards.
|
||||||
|
*/
|
||||||
|
const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
|
||||||
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
|
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
|
||||||
await scopedStore.updateTask(req.params.id, {
|
await scopedStore.updateTask(req.params.id, {
|
||||||
status: null,
|
status: null,
|
||||||
@@ -2465,8 +2551,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
...autoPauseClearPatch,
|
...autoPauseClearPatch,
|
||||||
...buildManualRetryResetPatch({ resetMergeRetries: true }),
|
...buildManualRetryResetPatch({ resetMergeRetries: true }),
|
||||||
});
|
});
|
||||||
await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (unusable worktree session-start recovery → todo, preserving progress${retryLogSuffix})`);
|
await scopedStore.logEntry(req.params.id, `Retry requested from dashboard (unusable worktree session-start recovery → ${reboundColumn}, preserving progress${retryLogSuffix})`);
|
||||||
const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
|
const updated = await scopedStore.moveTask(req.params.id, reboundColumn, { preserveProgress: true });
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2479,6 +2565,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
FNXC:WorkflowRetry 2026-06-29-02:18:
|
FNXC:WorkflowRetry 2026-06-29-02:18:
|
||||||
Dashboard retry for an in-review execution failure re-enters the workflow graph from parse/execution, so it must clear persisted foreach step-instance pins. Otherwise a stale pin from the failed run makes the retry hit the same parse pin-mismatch immediately.
|
Dashboard retry for an in-review execution failure re-enters the workflow graph from parse/execution, so it must clear persisted foreach step-instance pins. Otherwise a stale pin from the failed run makes the retry hit the same parse pin-mismatch immediately.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-11:05 (U12 review):
|
||||||
|
Resolve the rebound destination BEFORE logging so the audit entry reports the real
|
||||||
|
trait-derived column instead of a hardcoded "todo", which misleads on custom boards.
|
||||||
|
*/
|
||||||
|
const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
|
||||||
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
|
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
|
||||||
await scopedStore.updateTask(req.params.id, {
|
await scopedStore.updateTask(req.params.id, {
|
||||||
status: null,
|
status: null,
|
||||||
@@ -2489,10 +2581,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
await scopedStore.logEntry(
|
await scopedStore.logEntry(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
isInReviewExecutionStall
|
isInReviewExecutionStall
|
||||||
? `Retry requested from dashboard (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
|
? `Retry requested from dashboard (stranded in-review execution retry → ${reboundColumn}, preserving progress${retryLogSuffix})`
|
||||||
: `Retry requested from dashboard (execution failure in-review → todo, preserving progress${retryLogSuffix})`,
|
: `Retry requested from dashboard (execution failure in-review → ${reboundColumn}, preserving progress${retryLogSuffix})`,
|
||||||
);
|
);
|
||||||
const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
|
const updated = await scopedStore.moveTask(req.params.id, reboundColumn, { preserveProgress: true });
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2565,7 +2657,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
}
|
}
|
||||||
|
|
||||||
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (stuck kill budget reset)");
|
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (stuck kill budget reset)");
|
||||||
const updated = await scopedStore.moveTask(req.params.id, "todo");
|
const reboundColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
|
||||||
|
const updated = await scopedStore.moveTask(req.params.id, reboundColumn);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -2644,7 +2737,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
"Task reset by user — all progress cleared, fresh worktree and branch will be allocated",
|
"Task reset by user — all progress cleared, fresh worktree and branch will be allocated",
|
||||||
);
|
);
|
||||||
|
|
||||||
await scopedStore.moveTask(req.params.id, "todo");
|
const resetColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
|
||||||
|
await scopedStore.moveTask(req.params.id, resetColumn);
|
||||||
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
|
await clearRebuiltSpecWorkflowPins(scopedStore, req.params.id);
|
||||||
let updated = await scopedStore.getTask(req.params.id);
|
let updated = await scopedStore.getTask(req.params.id);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
@@ -3494,7 +3588,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move to todo and clear status
|
// Move to todo and clear status
|
||||||
const updated = await scopedStore.moveTask(task.id, "todo");
|
const reboundColumn = await resolveReboundColumnForTask(scopedStore, task.id);
|
||||||
|
const updated = await scopedStore.moveTask(task.id, reboundColumn);
|
||||||
await scopedStore.updateTask(task.id, {
|
await scopedStore.updateTask(task.id, {
|
||||||
status: undefined,
|
status: undefined,
|
||||||
...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}),
|
...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}),
|
||||||
@@ -4248,9 +4343,21 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
// Get current task state
|
// Get current task state
|
||||||
const task = await scopedStore.getTask(req.params.id);
|
const task = await scopedStore.getTask(req.params.id);
|
||||||
|
|
||||||
// If task is already in triage, skip the transition check and moveTask.
|
/*
|
||||||
// Just reset for replanning in place.
|
FNXC:WorkflowColumns 2026-07-19-11:10 (U12 review):
|
||||||
if (task.column === "triage") {
|
The in-place-reset early return must key on the workflow-resolved intake target, not only
|
||||||
|
the literal "triage". On a custom board whose intake column isn't "triage" (e.g. "backlog"),
|
||||||
|
a task already sitting at intake would otherwise fall through to
|
||||||
|
`canTransition = task.column !== respecifyTarget` === false and be rejected — permanently
|
||||||
|
blocking spec revision in exactly the column where respecify belongs. The literal "triage"
|
||||||
|
check is kept alongside so legacy behavior stays byte-identical even if a custom workflow
|
||||||
|
declares a non-intake column literally named "triage".
|
||||||
|
*/
|
||||||
|
const respecifyTarget = await resolveIntakeColumnForTask(scopedStore, task.id);
|
||||||
|
|
||||||
|
// If task is already at its workflow's intake column, skip the transition
|
||||||
|
// check and moveTask. Just reset for replanning in place.
|
||||||
|
if (task.column === "triage" || task.column === respecifyTarget) {
|
||||||
// Log the revision request
|
// Log the revision request
|
||||||
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
|
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
|
||||||
|
|
||||||
@@ -4273,11 +4380,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
// #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy
|
// #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy
|
||||||
// closed union. A non-legacy custom column id has no legacy transition row,
|
// closed union. A non-legacy custom column id has no legacy transition row,
|
||||||
// so it correctly resolves to "cannot transition" here.
|
// so it correctly resolves to "cannot transition" here.
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowColumns 2026-07-19-02:40 (U12 / R2):
|
||||||
|
`VALID_TRANSITIONS` is keyed by the closed legacy enum, so `isColumn(task.column)` was false
|
||||||
|
for every workflow-defined column and spec revision was unreachable on a custom board —
|
||||||
|
rejected with "Move task to 'todo' or 'in-progress' first", naming columns that workflow may
|
||||||
|
not have. Custom workflow columns are always eligible: the already-at-intake case returned
|
||||||
|
above, and the workflow itself declares the intake column we send the card to, so there is
|
||||||
|
no legacy table to consult. Legacy columns keep the legacy table.
|
||||||
|
*/
|
||||||
const canTransition =
|
const canTransition =
|
||||||
isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage");
|
!isColumn(task.column) || VALID_TRANSITIONS[task.column].includes("triage");
|
||||||
if (!canTransition) {
|
if (!canTransition) {
|
||||||
throw badRequest(
|
throw badRequest(
|
||||||
`Cannot request spec revision for tasks in '${task.column}' column. Move task to 'todo' or 'in-progress' first.`,
|
`Cannot request spec revision for tasks in '${task.column}' column.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4285,7 +4401,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
|
await scopedStore.logEntry(task.id, "AI spec revision requested", feedback);
|
||||||
|
|
||||||
// Move to triage for replanning
|
// Move to triage for replanning
|
||||||
const updated = await scopedStore.moveTask(task.id, "triage");
|
const updated = await scopedStore.moveTask(task.id, respecifyTarget);
|
||||||
|
|
||||||
// Remove the existing spec so replanning starts from the task
|
// Remove the existing spec so replanning starts from the task
|
||||||
// description and feedback rather than revising stale PROMPT.md content.
|
// description and feedback rather than revising stale PROMPT.md content.
|
||||||
@@ -5291,7 +5407,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (lastDoneStep) {
|
if (lastDoneStep) {
|
||||||
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
|
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
|
||||||
}
|
}
|
||||||
updatedTask = await scopedStore.moveTask(task.id, "in-progress", { preserveProgress: true });
|
const prFeedbackColumn = await resolveWipColumnForTask(scopedStore, task.id);
|
||||||
|
updatedTask = await scopedStore.moveTask(task.id, prFeedbackColumn, { preserveProgress: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveSession = Boolean(updatedTask.sessionFile);
|
const hasActiveSession = Boolean(updatedTask.sessionFile);
|
||||||
@@ -5323,7 +5440,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
assigneeUserId: null,
|
assigneeUserId: null,
|
||||||
status: null,
|
status: null,
|
||||||
});
|
});
|
||||||
const task = await scopedStore.moveTask(req.params.id, "todo");
|
const unassignColumn = await resolveReboundColumnForTask(scopedStore, req.params.id);
|
||||||
|
const task = await scopedStore.moveTask(req.params.id, unassignColumn);
|
||||||
res.json(task);
|
res.json(task);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user